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 webbrowser
import tkinter
from tkinter import ttk
from tkinter.font import Font
from tkinter.simpledialog import Dialog, askstring
from tkinter.filedialog import askdirectory
from tkinter.messagebox import showerror, showwarning, askyesno, askokcancel
from tkinter.scrolledtext import ScrolledText
from sage.version import version as sage_version
import os
import plistlib
import platform
import certifi
this_python = 'python' + '.'.join(platform.python_version_tuple()[:2])
contents_dir = abspath(path_join(sys.argv[0], pardir, pardir))
frameworks_dir = path_join(contents_dir, 'Frameworks')
info_plist = path_join(contents_dir, 'Info.plist')
current = path_join(frameworks_dir, 'Sage.framework', 'Versions', 'Current')
def get_version():
with open(info_plist, 'rb') as plist_file:
info = plistlib.load(plist_file)
return info['CFBundleShortVersionString']
sagemath_version = get_version()
app_name = 'SageMath-%s' % sagemath_version.replace('.', '-')
app_support_dir = path_join(os.environ['HOME'], 'Library', app_name)
settings_path = path_join(app_support_dir, 'Settings.plist')
jupyter_runtime_dir = os.path.join(app_support_dir, 'Jupyter', 'runtime')
sage_userbase = app_support_dir
sage_executable = path_join(current, 'venv', 'bin', 'sage')
sage_jupyter_path = path_join(current, 'venv', 'share', 'jupyter')
jp_pid_re = re.compile('jpserver-([0-9]*).*')
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:
jp_json_re = re.compile(r'jpserver-[0-9]*\.json')
url_fmt = 'http://localhost:{port}/{nb_type}?token={token}'
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
"""
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
"""
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 check_notebook_dir(self):
notebook_dir = self.notebook_dir.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 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'
def launch_terminal(self, app):
env = dict(self.environment)
env['PYTHONUSERBASE'] = sage_userbase
env['SSL_CERT_FILE'] = certifi.where()
env_str = " ".join(rf"{key}='{value}'" for key, value in env.items())
if app == 'Terminal.app':
sage_cmd = 'clear ; /usr/bin/env %s %s ; exit' % (env_str, sage_executable)
script = self.terminal_script % sage_cmd
subprocess.run(['osascript', '-'], input=script, text=True,
capture_output=True)
elif app == 'iTerm.app':
subprocess.run(['open', '-a', 'iTerm'], capture_output=True)
sage_cmd = '/usr/bin/env %s %s' % (env_str, sage_executable)
script = self.iterm_script % sage_cmd
subprocess.run(['osascript', '-'], input=script, text=True,
capture_output=True)
return True
def launch_notebook(self, notebook_type):
if not self.check_notebook_dir():
return False
notebook_dir = self.notebook_dir.get()
environ = {
'JUPYTER_RUNTIME_DIR': jupyter_runtime_dir,
'JUPYTER_PATH': sage_jupyter_path,
'PYTHONUSERBASE': sage_userbase,
'SSL_CERT_FILE': certifi.where()
}
environ.update(os.environ)
json_files = [f for f in os.listdir(jupyter_runtime_dir)
if self.jp_json_re.match(f)]
if json_files:
filename = os.path.join(jupyter_runtime_dir, json_files[0])
with open(filename) as json_file:
info = json.load(json_file)
info['nb_type'] = 'lab' if notebook_type=='jupyterlab' else 'tree'
if info['root_dir'] == notebook_dir:
url = self.url_fmt.format(**info)
subprocess.run(['open', url], env=environ)
return True
sage_executable = path_join(frameworks_dir, 'sage.framework', 'Versions',
'Current', 'venv', 'bin', 'sage')
subprocess.Popen([sage_executable, '-n', notebook_type,
'--notebook-dir=%s'%notebook_dir], env=environ)
return True
class LaunchWindow(tkinter.Toplevel, Launcher):
def __init__(self, root):
Launcher.__init__(self)
self.get_settings()
self.root = root
tkinter.Toplevel.__init__(self)
self.tk.call('::tk::unsupported::MacWindowStyle', 'style', self._w,
'document', 'closeBox')
self.protocol("WM_DELETE_WINDOW", self.close)
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='nsew')
self.update_idletasks()
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 = ttk.Labelframe(frame, text="Available User Interfaces",
padding=10)
self.radio_var = radio_var = tkinter.Variable(interfaces,
self.settings['state']['interface_type'])
self.use_cli = ttk.Radiobutton(interfaces, 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.settings['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(interfaces,
self.terminal_var, self.terminals)
self.use_jupyter = ttk.Radiobutton(interfaces, text="Notebook",
variable=radio_var, value='nb', command=self.update_radio_buttons)
self.notebook_types = ['Jupyter Notebook', 'JupyterLab']
favorite = self.settings['state']['notebook_type']
if favorite != 'Jupyter Notebook' and favorite in self.notebook_types:
self.notebook_types.remove(favorite)
self.notebook_types.insert(0, favorite)
self.nb_var = tkinter.Variable(self, self.notebook_types[0])
self.notebook_option = PopupMenu(interfaces, self.nb_var,
self.notebook_types)
notebook_dir_frame = ttk.Frame(interfaces)
ttk.Label(notebook_dir_frame, text='Using notebooks from:').grid(
row=0, column=0, sticky='w', padx=12)
self.notebook_dir = ttk.Entry(notebook_dir_frame, width=24)
self.notebook_dir.insert(tkinter.END, self.settings['state']['notebook_dir'])
self.notebook_dir.config(state='readonly')
self.browse = ttk.Button(notebook_dir_frame, text='Select ...', padding=(-8, 0),
command=self.browse_notebook_dir, state=tkinter.DISABLED)
self.notebook_dir.grid(row=1, column=0, padx=8)
self.browse.grid(row=1, column=1)
self.use_cli.grid(row=0, column=0, sticky='w', pady=5)
self.terminal_option.grid(row=1, column=0, sticky='w', padx=10, pady=5)
self.use_jupyter.grid(row=2, column=0, sticky='w', pady=5)
self.notebook_option.grid(row=3, column=0, sticky='w', padx=10, pady=5)
notebook_dir_frame.grid(row=4, column=0, sticky='w', pady=5)
launch_frame = ttk.Frame(frame)
self.launch = ttk.Button(launch_frame, text="Launch", command=self.launch_sage)
self.launch.pack()
logo.grid(row=0, column=0, pady=5)
interfaces.grid(row=2, column=0, padx=10, pady=10, sticky='ew')
launch_frame.grid(row=3, column=0)
self.geometry('380x390+400+400')
self.update_radio_buttons()
self.update_idletasks()
def close(self):
self.withdraw()
default_settings = {
'environment': {
},
'state': {
'interface_type': 'cli',
'terminal_app': 'Terminal.app',
'notebook_type': 'Jupyter Notebook',
'notebook_dir': '',
},
}
def get_settings(self):
settings = self.default_settings.copy()
try:
with open(settings_path, 'rb') as settings_file:
saved_settings = plistlib.load(settings_file)
except:
saved_settings = None
if saved_settings:
for key in settings:
settings[key].update(saved_settings.get(key, {}))
self.settings = settings
def save_settings(self):
self.get_settings()
self.settings['state'].update(
{
'interface_type': self.radio_var.get(),
'terminal_app': self.terminal_var.get(),
'notebook_type': self.nb_var.get(),
'notebook_dir': self.notebook_dir.get(),
}
)
try:
with open(settings_path, 'wb') as settings_file:
plistlib.dump(self.settings, settings_file)
except:
pass
def update_radio_buttons(self):
radio = self.radio_var.get()
if radio == 'cli':
self.notebook_dir.config(state=tkinter.DISABLED)
self.browse.config(state=tkinter.DISABLED)
self.terminal_option.config(state=tkinter.NORMAL)
self.notebook_option.config(state=tkinter.DISABLED)
elif radio == 'nb':
self.notebook_dir.config(state='readonly')
self.browse.config(state=tkinter.NORMAL)
self.notebook_option.config(state=tkinter.NORMAL)
self.terminal_option.config(state=tkinter.DISABLED)
def update_environment(self):
required_paths = [
'/var/tmp/sage-10.3-current/local/bin',
'/var/tmp/sage-10.3-current/venv/bin',
'/bin',
'/usr/bin',
'/usr/local/bin',
'/Library/TeX/texbin'
]
try:
with open(settings_path, 'rb') as settings_file:
settings = plistlib.load(settings_file)
environment = settings.get('environment', {})
except:
environment = {}
user_paths = environment.get('PATH', '').split(':')
paths = [path for path in user_paths if path] + required_paths
unique_paths = list(dict.fromkeys(paths))
environment['PATH'] = ':'.join(unique_paths)
self.environment = environment
def launch_sage(self):
self.update_environment()
interface = self.radio_var.get()
if interface == 'cli':
launched = self.launch_terminal(app=self.terminal_var.get())
elif interface == 'nb':
app = self.nb_var.get()
if not app in self.notebook_types:
app = 'Jupyter Notebook'
self.nb_var.set(app)
if app == 'JupyterLab':
launched = self.launch_notebook('jupyterlab')
else:
launched = self.launch_notebook('jupyter')
if launched:
self.save_settings()
self.close()
def browse_notebook_dir(self):
directory = askdirectory(parent=self, initialdir=os.environ['HOME'],
message='Choose or create a folder for Jupyter notebooks')
if directory:
self.notebook_dir.config(state=tkinter.NORMAL)
self.notebook_dir.delete(0, tkinter.END)
self.notebook_dir.insert(tkinter.END, directory)
self.notebook_dir.config(state='readonly')
class AboutDialog(Dialog):
def __init__(self, parent, title='', content=''):
self.content = content
self.style = ttk.Style(parent)
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, parent, title=title)
def body(self, parent):
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='n')
message = tkinter.Message(frame, text=self.content)
message.grid(row=1, column=0, padx=20, sticky='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("<Return>", self.ok)
self.bind("<Escape>", self.ok)
frame.pack()
class InfoDialog(Dialog):
def __init__(self, parent, title='', message='',
text_width=40, text_height=12, font_size=16):
self.message = message
self.text_width, self.text_height = text_width, text_height
self.text_font = tkinter.font.Font()
self.text_font.config(size=font_size)
self.style = ttk.Style(parent)
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, parent, title=title)
def body(self, parent):
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='n')
font = tkinter.font.Font()
font.config(size=18)
text = tkinter.Text(frame, wrap=tkinter.WORD, bd=0,
highlightthickness=0, bg='SystemWindowBackgroundColor',
width=self.text_width, height=self.text_height,
font=self.text_font)
text.grid(row=1, column=0, padx=20, sticky='ew')
text.insert(tkinter.INSERT, self.message)
text.config(state=tkinter.DISABLED)
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("<Return>", self.ok)
self.bind("<Escape>", self.ok)
frame.pack()
class EnvironmentEditor(tkinter.Toplevel):
def __init__(self, parent):
tkinter.Toplevel.__init__(self, parent)
self.parent = parent
self.wm_protocol('WM_DELETE_WINDOW', self.close)
self.title('Sage Environment')
home = os.environ['HOME']
self.load_settings()
self.environment = self.settings.get('environment', {})
self.varlist = list(self.environment.keys())
self.add = tkinter.Image('nsimage', name='add', source='NSAddTemplate',
width=20, height=20)
self.remove = tkinter.Image('nsimage', name='remove', source='NSRemoveTemplate',
width=20, height=4)
self.left = ttk.Frame(self, padding=0)
ttk.Label(self, text = 'Variable').grid(row=0, column=0, padx=10, sticky='w')
ttk.Label(self, text = 'Value').grid(row=0, column=1, sticky='w')
self.varnames = tkinter.StringVar(self)
if self.varlist:
self.varnames.set(self.varlist)
self.listbox = tkinter.Listbox(self.left, selectmode='browse',
listvariable=self.varnames, height=19)
self.listbox.grid(row=1, column=0, columnspan=2, sticky='nsew')
button_frame = ttk.Frame(self.left, padding=(0, 4, 0, 10))
ttk.Button(button_frame, style="GradientButton", image='add',
command=self.add_var).grid(row=0, column=0, sticky='nw')
ttk.Button(button_frame, style="GradientButton", image='remove',
padding=(0,8), command=self.remove_var).grid(row=0, column=1, sticky='nw')
button_frame.grid(row=2, column=0, sticky='nw')
self.columnconfigure(1, weight=1)
self.rowconfigure(1, weight=1)
self.left.grid(row=1, rowspan=2, column=0, sticky='nsw', padx=10, pady=10)
self.text = ScrolledText(self)
self.text.frame.grid(row=1, column=1, pady=10, sticky='nsew')
ttk.Button(self, text='Done', command = self.done).grid(
row=2, column=1, pady=20, padx=20, sticky='es')
self.listbox.bind("<<ListboxSelect>>",
lambda e: self.update())
self.selected = None
if self.varlist:
self.listbox.selection_set(0)
self.update()
def update(self):
if self.selected is not None:
current_value = self.text.get('0.0', 'end').strip()
self.environment[self.listbox.get(self.selected)] = current_value
selection = self.listbox.curselection()
if not selection:
return
selection = selection[0]
self.selected = selection
self.text.delete('0.0', 'end')
var = self.listbox.get(selection).strip()
value = self.environment.get(var, '')
if value:
self.text.insert('0.0', value)
def add_var(self):
self.update()
new_var = askstring('New Variable', 'Variable Name:')
self.environment[new_var] = ''
self.text.delete('0.0', 'end')
self.selected = len(self.varlist)
self.varlist.append(new_var)
self.listbox.insert('end', new_var)
self.listbox.selection_clear(0, 'end')
self.listbox.selection_set(self.selected)
self.listbox.see(self.selected)
self.text.focus_set()
def remove_var(self):
selection = self.listbox.curselection()
if not selection:
return
var = self.listbox.get(selection[0])
self.varlist.remove(var)
self.environment.pop(var)
self.text.delete('0.0', 'end')
self.varnames.set(self.varlist)
if '' in self.environment:
self.environment.pop('')
def go(self):
self.transient(self.parent)
self.grab_set()
self.wait_window(self)
def load_settings(self):
if os.path.exists(settings_path):
try:
with open(settings_path, 'rb') as settings_file:
self.settings = plistlib.load(settings_file)
except plistlib.InvalidFileException:
os.unlink(settings_path)
self.settings = {}
else:
self.settings = {}
def done(self):
self.update()
if '' in self.environment:
self.environment.pop('')
self.settings['environment'] = self.environment
with open(settings_path, 'wb') as settings_file:
plistlib.dump(self.settings, settings_file)
self.destroy()
def close(self):
if askokcancel(message=''
'Closing the window will cause your changes to be lost.'):
self.destroy()
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):
os.makedirs(jupyter_runtime_dir, exist_ok=True)
os.makedirs(sage_userbase, exist_ok=True)
self.root_window = root = tkinter.Tk()
root.withdraw()
os.chdir(os.environ['HOME'])
self.icon = tkinter.Image("photo", file=self.icon_file)
root.tk.call('wm','iconphoto', root._w, self.icon)
self.menubar = menubar = tkinter.Menu(root)
root.createcommand('::tk::mac::ShowPreferences', self.edit_env)
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").pack(padx=20, pady=20)
def about_sagemath(self):
AboutDialog(self.root_window, 'SageMath', self.about)
def edit_env(self):
editor = EnvironmentEditor(self.launcher)
editor.go()
def run(self):
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.createcommand('tk::mac::ReopenApplication',
self.launcher.deiconify)
self.root_window.createcommand('tk::mac::Quit', self.quit)
self.root_window.mainloop()
def shutdown_servers(self):
try:
jp_files = os.listdir(jupyter_runtime_dir)
except:
return
pids = set()
for filename in jp_files:
m = jp_pid_re.match(filename)
if m:
pids.add(m.groups()[0])
if pids:
answer = askokcancel(
message='Quitting the SageMath app will terminate '
'all notebooks. Unsaved changes will be lost.')
if answer == False:
return False
for pid in pids:
try:
os.kill(int(pid), signal.SIGTERM)
except:
pass
for filename in jp_files:
if filename == 'jupyter_cookie_secret':
continue
if os.path.exists(filename):
os.unlink(filename)
return True
def quit(self):
if self.shutdown_servers():
self.launcher.destroy()
self.root_window.destroy()
if __name__ == '__main__':
SageApp().run()