""" A script for launching a Jupyter Notebook server so that .ipynb files can be opened via the browser. The server will be launched in the background so that it keeps running even if Thonny stops or runs something else. If it can't find the `notebook` module, it will first attempt to use pip to upgrade `pip` and `wheel` and then install `notebook`, which should install everything necessary to run a Jupyter Notebook server. This may not work if some of the necessary wheels are not available for your architecture via PyPI, in which case you may see instructions for installing a compiler on your machine, which you can follow to get things working (although this step is not trivial...). Please reach out to an instructor if you have any issues during the installation process. This script looks for any running server with a root directory that should be compatible with the target file, and opens the file using that server if possible. If no running server has an appropriate root directory, a new one will be launched in the directory containing the target file. If no target file is given, the current working directory will be opened, again either in an existing compatible server or in a new one. The `PREFERRED_PORT` will be used if no server is already running on that port, otherwie a different port will be chosen. The server it launches will remain running indefinitely. AUTHOR: Peter Mawhorter, Lyn Turbak CHANGELOG: ## Version 3.3 * Adds a "pip install --user --upgrade typing_extensions" call to the Jupyter notebook setup to try to smooth over a dependency issue. ## Version 3.2 * Fixes a bug on Windows where Unix PATH separator was used, causing failure to launch in some situations. ## Version 3.1 * Now checks root directories of servers in an attempt to find one that's compatible with the target file. `CONNECT_ONLY_ON_PORT` has been changed to `PREFERRED_PORT` and the behavior is not as strict. ## Version 3.0 * Instead of always attempting to connect instead of launching a new server, this version will always launch a new server, as long as no server exists at a particular port, but will launch a new server even if servers exists with different ports than the one specified. It always launches a server on that specified port. So you can distribute multiple versions of the file, with different port settings (see `CONNECT_ONLY_ON_PORT`). ## Version 2.1 * Attempts to use custom cs111 package index as an extra package index when installing Jupyter. ## Version 2.0 * Attempts to run indefinitely in the background, and if it sees a running server already, just tries to connect to that. This is a design change to try to reduce dead kernels + allow operating multiple notebooks at once using the same server more easily when run via Thonny. ## Version 1.0 * Minor reformatting from Lyn's 0.10.2 * Changes to debugging output. * Removed quote-escaping which was causing problems after the move to list-of-strings based command-line construction. * An optimistic version bump in preparation for sending to students. # Version 0.10.2 (made by Lyn to Peter's 0.10.1) * Update server/kernel timeouts in documentation at top of file * Print version number * targetNames needs to include .exe extension for Windows, no extension for Macs. e.g., target "jupyter-notebook" on Mac is "jupyter-notebook.exe" in Windows. * print path using os.environ["PATH"], not subprocess.run("echo $PATH", ...), which doesn't work in Windows * change jupyter_exec and opt to be *lists of strings* rather than *strings". + subprocess.run excepts either format, but for single strings, Windows is confused by executables with spaces like C:\\Program Files (x86)\\Thonny\\python.exe (because it thinks the exectuable is "C:\\Program" and that "Files" and "(x86)\\Thonny\\python.exe" are the first two arguments). No such confusion occurs when the format is lists of strings. + IMPORTANT: when commands are a *list of strings*, shell keyword arg *must* be False! (the default) """ import os import sys import subprocess import urllib.parse import pathlib import sysconfig import webbrowser __version__ = "3.3" """ Current version number. """ PREFERRED_PORT = "9123" """ Set this to the specific port that we should use. If it's already in use, the next open port should be picked by default. """ USE_TIMEOUTS = False """ Whether to use timeouts for the server, or leave it running indefinitely. """ print(f"Running launchNotebook version {__version__}") # Figure out our target directory if len(sys.argv) > 2: print( "Ignoring extra arguments. You may use a single argument to" " specify which file to open, or you may use '-h' or '--help'," " or you may use no arguments. Multiple arguments do not make" " sense. Perhaps you forgot to quote a path that has a space in" " it?" ) if len(sys.argv) == 2: if sys.argv[1] in ('-h', '--help'): print( "This is a program to run .ipynb notebook files. It first" " tries to install the 'notebook' Python package so that it" " can start a notebook server, and then looks for an" " already-running server it can use, or starts a new one." " If a target notebook is specified, it should open in your" " default web browser. If not, a Jupyter file browser for" " the current directory should be opened in your browser." " If a page doesn't open in your browser, look for an error" " message printed out. There may also be links printed out" " that you can copy-paste into your browser to connect." "\n\nSince you asked for help, we are not going to launch a" " server. Run without the '-h' or '--help' argument to do" " that." ) sys.exit(0) # first arg must be the target file/dir target = pathlib.Path(sys.argv[1]) target = target.resolve(strict=False) if os.path.isfile(target): targetDir = target.parent target = target.name else: targetDir = target target = None else: target = None targetDir = pathlib.Path.cwd() # Try to find whether Jupyter is installed or not try: import notebook # noqa F401 except Exception: # Jupyter isn't installed, so we'll try to install it print( "We couldn't find Jupyter Notebook on your system, so we'll try" " to install it first." ) subprocess.check_call( [ sys.executable, '-m', 'pip', 'install', '--user', '--upgrade', 'pip', 'wheel' ] ) subprocess.check_call( [ sys.executable, '-m', 'pip', 'install', '--user', 'notebook', '--extra-index-url', 'https://cs111.wellesley.edu/content/pypackages' ] ) # Additional workaround for typing_extensions dependency issue in # IPython core. subprocess.check_call( [ sys.executable, '-m', 'pip', 'install', '--user', '--upgrade', 'typing_extensions', '--extra-index-url', 'https://cs111.wellesley.edu/content/pypackages' ] ) # Find ALL scripts directories with 'jupyter' OR 'jupyter-notebook' in # them, and add them to our PATH, since even running via python module # import bounces to the shell PATH at some point T_T. hits = set() # Looking for one of these targets in packages targetNames = set( [ name + ext for name in ['jupyter', 'jupyter-notebook'] for ext in [ '', # Mac has no extension '.exe' # Windows has .exe extension ] ] ) # Look for jupyter scripts in every possible script install dir print("Looking for Jupyter script...") look_in = set() schemes_found = set() for scheme in sysconfig.get_scheme_names(): bindir = sysconfig.get_path('scripts', scheme) if bindir is not None and os.path.exists(bindir): look_in.add(bindir) schemes_found.add(scheme) print( f"Found {len(look_in)} directories to search, from schemes:" f" {schemes_found}" ) for bindir in look_in: print(f"Looking for Jupyter in '{bindir}':") matches = set(os.listdir(bindir)).intersection(targetNames) if len(matches) > 0: hits.add(bindir) print(" Found: " + ', '.join(matches)) else: print(" No Jupyter scripts here.") if len(hits) == 0: # If we can't find jupyter, try anyways because maybe it's elsewhere # on their PATH? But print a warning message... print( "Even after installing Jupyter Notebook, we can't figure out" " how to launch it. We're going to try anyways, but it will" " probably crash." ) else: # Add entries at the end of the PATH for all the Python script dirs # we found with 'jupyter' in them. path = os.environ["PATH"] print("Adding entries to PATH:") for bindir in hits: print(' ' + bindir) path += os.pathsep + bindir os.environ["PATH"] = path # Don't use subprocess.run with "echo $PATH" because in Windows 10 # this returns the string "$PATH" and not a string with path directories. finalPath = os.environ["PATH"] print("Your current PATH entries are:") for entry in finalPath.split(os.pathsep): print(' ' + entry) # This is how we'll launch jupyter... # Put command, args, opts etc in a *list of strings* # rather than *single string* # so that spaces in program names like # C:\\Program Files (x86)\\Thonny\\python.exe # don't cause an error in Windows in subprocess.run jupyter_exec = [sys.executable, '-m', 'jupyter_core.command'] print(f"Running {jupyter_exec + ['notebook', 'list']}") # Check for already-running servers: result = subprocess.run( jupyter_exec + ['notebook', 'list'], # shell=True, # shell must be False (default) when command is a list capture_output=True, text=True ) if result.stdout: print(result.stdout) # Abort if jupyter is not installed if result.returncode != 0: print( f"Jupyter Notebook server isn't working on your system (return" f" code {result.returncode}). You should ask an instructor for" f" help with this, or if you can, install Anaconda and use that" f" to open the notebook." ) sys.exit(1) candidates = [] if result.stdout and len(result.stdout.splitlines()) > 1: lines = result.stdout.splitlines()[1:] print("One or more notebook servers are already active:") for line in lines: url, rootPath = line.split('::') url = url.strip() rootPath = pathlib.Path(rootPath.strip()) print(' ' + url + ' at path ' + repr(rootPath)) if targetDir.is_relative_to(rootPath): candidates.append((url, rootPath)) if len(candidates) > 0: print("Found " + str(len(candidates)) + " viable servers.") url, root = candidates[0] print("Launching using the first one:") print(' ' + url + ' at path ' + repr(root)) urlPath = urllib.parse.quote(targetDir.relative_to(root).as_posix()) if target is None: webpath = '/tree/' + urlPath else: webpath = '/notebooks/' + urlPath + '/' + urllib.parse.quote(target) pieces = urllib.parse.urlparse(url) fullURL = urllib.parse.urlunparse(pieces._replace(path=webpath)) print("Opening: " + fullURL) webbrowser.open_new_tab(fullURL) exit() else: print( "Didn't find any server that can open the target; launching a" " new one." ) # Figure out web path if target is None: webpath = '/tree' else: webpath = '/notebooks/' + urllib.parse.quote(target) # TODO: Use urlopen for this case (after launching server, maybe with # a delay?...) # Note: escaping quotes would be bad since we're using the # list-of-strings command-line approach opts = [ # prevent async io errors on Mac "--NotebookApp.kernel_manager_class=" + "notebook.services.kernels.kernelmanager.AsyncMappingKernelManager", ] if PREFERRED_PORT: opts += ["--port=" + PREFERRED_PORT] if USE_TIMEOUTS: opts += [ "--NotebookApp.shutdown_no_activity_timeout=14400", # 240 minutes "--MappingKernelManager.cull_idle_timeout=9000", # 150 minutes ] # Set up root directory opts += [f'--notebook-dir="{targetDir}"'] # run without a target to launch into the Jupyter file browser args = jupyter_exec + ['notebook'] # Using Popen here instead of "run" so that it launches in the # background and we don't wait for it to complete. print(f'Running:\n{args + opts}') if hasattr(subprocess, "DETACHED_PROCESS"): flags = subprocess.DETACHED_PROCESS else: flags = 0 subprocess.Popen( args + opts, # shell=True, # shell must be False (default) when command is a list close_fds=True, # some places suggest this helps detach things? creationflags=flags # may also help detach? )