initial dev work completed and tested

This commit is contained in:
Brennen Raimer
2024-05-13 23:15:45 -04:00
parent 40ba62ff6c
commit 68c7272752
4 changed files with 658 additions and 3 deletions

View File

@@ -1,14 +1,56 @@
# App Mode Jupyter Environment and Shortcut
Adds a shortcut for running Jupyter Lab in a Chromium-based browser's "app" mode (i.e. The application takes up the full browser window and has no browser UI e.g. the address bar, as if Jupyter Lab were a native application) for an existing environment with Jupyter Lab installed or creating one for you.
Adds a shortcut for running [JupyterLab](https://jupyter.org/) in a Chromium-based browser's "app" mode (i.e. The application takes up the full browser window and has no browser UI e.g. the address bar, as if Jupyter Lab were a native application) for an existing environment with Jupyter Lab installed or creating one for you.
## Requirements
* An conda-based installation of [Anaconda](https://anaconda.org/), [Miniconda](https://docs.conda.io/en/latest/miniconda.html) or [Miniforge](https://github.com/conda-forge/miniforge). Miniforge is recommended.
* [`menuinst`](https://conda.github.io/menuinst/) >= 2.0.0 must be installed in the `base` environment alongside `conda`
* [`menuinst`](https://conda.github.io/menuinst/) >= 2.0.0 must be installed in the `base` environment alongside `conda` or in whichever environment contains conda
* A Chromium-based browser
* On Windows, [Google Chrome](https://www.google.com/chrome/), [Brave](https://brave.com/), and [Microsoft Edge](https://www.microsoft.com/en-us/edge) are supported. Preference will be given to the first one of these browsers found. Microsoft Edge is guaranteed to be installed on Windows 10+ and is a fall-back if no other supported browser is found.
* On MacOS, [Google Chrome](https://www.google.com/chrome/), [Microsoft Edge](https://www.microsoft.com/en-us/edge), [Brave](https://brave.com/), and [Chromium](https://www.chromium.org/getting-involved/download-chromium/) are preferred in that order.
* On Linux, [Google Chrome](https://flathub.org/apps/com.google.Chrome), [Microsoft Edge](https://flathub.org/apps/com.microsoft.Edge), [Brave](https://flathub.org/apps/com.brave.Browser), and [Chromium](https://flathub.org/apps/org.chromium.Chromium) are supported and preferred in that order. If [flatpak](https://flatpak.org/) is installed and one of these browsers is installed with Flatpak (recommended), it will be preferred over any snap-installed or package manager-installed versions of any of these browsers.
## Setup
1. Clone or download this repository
2. From a terminal active in your `base` conda environment (or whichever environment conda is installed in), execute `python ./setup_jupyter.py --name your_jupyter_env_name_here`
2. From a terminal, activate your `base` conda environment (or whichever environment conda is installed in)
3. Run `python ./setup_jupyter.py your_jupyter_env_name_here`
## Remove Shortcut
To remove the shortcut created by this script, run `python ./setup_jupyter.py --remove your_jupyter_env_name_here`
## FAQ
1. Why don't you support Mozilla Firefox or Safari?
To my knowledge, neither have an "app-mode" like Chromium-based browsers do. When/if they ever do, they will be supported.
2. Why am I being prompted for admin permissions?
By default, `menuinst` tries to install/remove the shortcut from a system-level location. You can safely cancel/ignore that prompt(s) (it may prompt you multiple times) which will cause `menuinst` to fallback to a user-specific location instead. Unfortunately, there does not seem to be a way to have `menuinst` prefer to install/remove shortcuts for the current user rather than system-wide.
3. What about other Chromium-based browsers?
I added support for what I perceived to be the most popular Chromium-based browsers that I am familiar with in order of my perception of their popularity. There's just so many of them and it seems like there's always new ones being released! Feel free to open a pull-request to add additional browsers.
4. What about dev/canary builds of the browsers?
Supporting the dev/canary builds of every browser on every platform would greatly increase the complexity of this code for marginal gains. You should not be using those as your everyday browser and you probably have a stable version of that same browser installed anyway.
5. Why is support for MacOS so buggy?
The last MacBook that I owned was a 2013 MacBook Air which now runs Linux. While I have attempted to support MacOS as best I can, I am working from my own recollection of using conda-based Python on MacOS combined with whatever I could find on google regarding the MacOS icon format and executing browsers from the CLI. I am not able to actually test this code on MacOS. I welcome any bug reports (and, hopefully, a pull-request for a fix) for any bugs, especially ones affecting MacOS.
6. What about my existing JupyterLab configuration?
The `jupyter_lab_config.py` used by the shortcut only sets values for `ServerApp.root_dir` and `ServerApp.browser`. If you have a custom `~/.jupyter/jupyter_lab_config.py` file, its contents are executed at the end of the shortcut's `jupyter_lab_config.py`, adding to or overriding any configuration values set by by it.
7. What is `nb_conda_kernels` and why does this script want to install it in my Jupyter environment.
`nb_conda_kernels` allows JupyterLab to run kernels in **any** installed conda environment which has a Jupyter kernel (e.g. ipykernel for Python) installed in it without having to install Jupyter and all its dependencies into that conda environment. It allows you to have a single version of JupyterLab installed in a single, purpose-made environment so you are not maintaining multiple JupyterLab versions or configurations.
8. How should I update my Jupyter environment?
That's up to you. I will periodically run `conda update -n jupyter --all && conda update -n jupyter python` to update everything in my Jupyter environment (named `jupyter`).

99
jupyter_lab_config.py Normal file
View File

@@ -0,0 +1,99 @@
import logging
import platform
from io import StringIO
from itertools import product
from os import environ
from pathlib import Path
from subprocess import CalledProcessError, run
logging.getLogger(__name__).propagate=True
def find_flatpak_browser():
if platform.system() != "Linux":
return
for path_element in map(Path, environ["PATH"].split(":")):
if (path_element/"flatpak").is_file():
flatpak_bin = path_element/"flatpak"
break
else:
# flatpak is not installed
return
flatpak_apps = ("com.google.Chrome", "com.microsoft.Edge", "com.brave.Browser", "org.chromium.Chromium")
try:
flatpak_process = run(
[
str(flatpak_bin),
"list",
"--app",
"--columns=application"
],
capture_output=True,
check=True
)
except CalledProcessError as e:
pass
else:
with StringIO(flatpak_process.stdout.decode()) as flatpak_output:
installed_flatpaks = flatpak_output.readlines()[1:]
for flatpak_app in flatpak_apps:
if flatpak_app in [_.strip() for _ in installed_flatpaks]:
return f"flatpak run --filesystem={Path.home()/'.local/share/jupyter/runtime'} {flatpak_app} --start-maximized --profile-directory=Default --app=%s"
def find_browser():
# find a chromium-based browser on the system to use.
if platform.system() == "Windows":
cmds = (
"Google/Chrome/Application/chrome.exe",
"BraveSoftware/Brave-Browser/Application/brave.exe",
"Microsoft/Edge/Application/msedge.exe",
)
path_elements = map(Path, (environ["ProgramFiles"], environ["ProgramFiles(x86)"]))
elif platform.system() == "Linux":
# prefers Google Chrome on Linux due to popularity of the browser
cmds = ("google-chrome", "microsoft-edge", "brave", "chromium")
path_elements = map(Path, environ["PATH"].split(":"))
elif platform.system() == "Darwin":
# also prefers Google Chrome on MacOS due to popularity of the browser
cmds = (
"Contents/MacOS/Google Chrome",
"Contents/MacOS/Microsoft Edge",
"Contents/MacOS/Brave Browser",
"Contents/MacOS/Chromium",
)
path_elements = [Path(f"/Applications/{Path(cmd).name}.app") for cmd in cmds]
# use the first chromium-based browser found as the browser to run Jupyter in app mode
for cmd, path_element in product(cmds, path_elements):
browser = path_element / cmd
if browser.exists():
return f'"{browser}" --start-maximized --profile-directory=Default --app=%s'
# generated if you run `jupyter lab --generate-config`, used for additional configuration
DEFAULT_CONFIG_FILE = Path.home() / ".jupyter/jupyter_lab_config.py"
# user's home will be the root path of jupyter's file explorer
c.ServerApp.root_dir = str(Path.home())
browser_cmd = find_flatpak_browser() or find_browser()
if browser_cmd:
c.ServerApp.browser = browser_cmd
else:
logging.getLogger(__name__).warning(
"No Chromium-based browser was found on this system, therefore Jupyter will run "
+ "in a new tab of the system-default browser"
)
# read and exec default config file for additional config/overrides
if DEFAULT_CONFIG_FILE.exists():
with open(DEFAULT_CONFIG_FILE, "r") as config_file:
exec(config_file.read())

56
jupyterlab_shortcut.json Normal file
View File

@@ -0,0 +1,56 @@
{
"menu_name": "{{ DISTRIBUTION_NAME }}",
"menu_items": [
{
"name": "JupyterLab",
"description": "Interactive Python Notebook",
"command": [
"jupyter",
"lab",
"--config={{ MENU_DIR }}/jupyter_lab_config.py"
],
"activate": true,
"terminal": true,
"working_dir": "{{ HOME }}",
"icon": "{{ MENU_DIR }}/jupyterlab.{{ ICON_EXT }}",
"platforms": {
"win": {
"file_extensions": [
".ipynb"
],
"quicklaunch": false
},
"osx": {
"CFBundleSpokenName": "Jupiter lab",
"entitlements": [
"com.apple.security.cs.allow-dyld-environment-variables",
"com.apple.security.cs.disable-library-validation",
"com.apple.security.cs.allow-jit",
"com.apple.security.cs.allow-unsigned-executable-memory",
"com.apple.security.cs.debugger",
"com.apple.security.network.client",
"com.apple.security.network.server",
"com.apple.security.files.user-selected.read-only",
"com.apple.security.inherit",
"com.apple.security.automation.apple-events"
]
},
"linux":{
"Categories": [
"Development",
"Science"
],
"Keywords": [
"python",
"jupyter",
"notebook"
],
"GenericName": "Interactive Python Notebook",
"MimeType": [
"application/x-ipynb+json"
]
}
}
}
]
}

458
setup_jupyter.py Executable file
View File

@@ -0,0 +1,458 @@
#!/bin/env python
import logging
import platform
from argparse import ArgumentParser
from json import JSONDecodeError, loads
from os import PathLike, environ, linesep
from pathlib import Path
from shutil import copy
from subprocess import CalledProcessError, run
from sys import argv, stderr, stdout
from tempfile import TemporaryDirectory
from typing import Union
from urllib.parse import urlparse
from requests import get
from requests.exceptions import RequestException
LOGGER = logging.getLogger(__name__)
class OperationCancelled(Exception):
pass
def get_current_prefix() -> Path:
try:
current_prefix = Path(environ["CONDA_PREFIX"])
except KeyError:
try:
# I vaguely recall MacOS prefixing the conda environment variables with _ for some stupid reason
current_prefix = Path(environ["_CONDA_PREFIX"])
except KeyError as e:
raise RuntimeError("No active conda environment detected. Please activate your base conda environment") from e
LOGGER.debug(f"Current prefix is {current_prefix}")
return current_prefix
def get_base_prefix() -> Path:
try:
conda_exe = Path(environ["CONDA_EXE"])
except KeyError:
try:
conda_exe = Path(environ["_CONDA_EXE"])
except KeyError as e:
raise RuntimeError("No active conda environment detected. Please activate your base conda environment") from e
LOGGER.debug(f"Base prefix is {conda_exe.parents[1]}")
# your base environment will be the grandparent of the conda executable
return conda_exe.parents[1]
def get_menuinst_version() -> tuple[str, str, str]:
conda_process = run(["conda", "list", "--prefix", str(get_base_prefix()), "--json"], capture_output=True, check=True)
try:
conda_pkgs = loads(conda_process.stdout)
except JSONDecodeError as decode_exception:
raise JSONDecodeError(f"Error parsing conda output as JSON: {decode_exception}") from decode_exception
for pkg in conda_pkgs:
if pkg["name"] == "menuinst":
break
else:
raise RuntimeError("menuinst was not found in the base conda envirionment")
LOGGER.debug(f"menuinst=={pkg['version']} found")
return pkg["version"].split(".")
def in_base_env() -> bool:
return get_current_prefix().samefile(get_base_prefix())
def menuinst_gt_v2_present() -> bool:
major, minor, patch = get_menuinst_version()
return (int(major) >= 2) and patch.isnumeric()
def meets_prerequisites() -> bool:
return in_base_env() and menuinst_gt_v2_present()
def stage_configs(destination_dir: Path) -> Path:
repo_dir = Path(__file__).parent
shortcut_json = repo_dir / "jupyterlab_shortcut.json"
jupyterlab_config = repo_dir / "jupyter_lab_config.py"
LOGGER.debug(f"Repository directory is {repo_dir}")
LOGGER.debug(f"menuinst spec json file is {shortcut_json}")
LOGGER.debug(f"JupyterLab config file is {jupyterlab_config}")
# download an icon file referenced in shortcut spec json
download_icon_file(destination_dir)
if not destination_dir.samefile(repo_dir):
LOGGER.debug(f"The config files will be staged in {destination_dir}")
copy(jupyterlab_config, destination_dir)
copy(shortcut_json, destination_dir)
return destination_dir / shortcut_json.name
else:
LOGGER.debug(f"Repository directory {repo_dir} is already the environment Menu directory. No need to stage config files")
return shortcut_json
def download_icon_file(menu_dir: PathLike):
LOGGER.debug(f"OS is {platform.system()}")
match platform.system():
case "Windows":
url = "https://raw.githubusercontent.com/jupyterlab/jupyterlab-desktop/master/dist-resources/icons/icon.ico"
case "Linux":
url = "https://raw.githubusercontent.com/jupyterlab/jupyterlab-desktop/master/dist-resources/icons/512x512.png"
case "Darwin":
url = "https://raw.githubusercontent.com/jupyterlab/jupyterlab-desktop/8614277f274b0e9ee9cc550d194e4f02d0c5c3c7/dist-resources/icon.svg"
case _:
raise RuntimeError(f"{platform.system()} is not a supported platform")
try:
LOGGER.debug(f"Downloading {url} to {menu_dir} for use as shortcut icon")
response = get(url)
except RequestException as e:
raise RuntimeError("Unable to download an icon to use for the JupyterLab shortcut") from e
else:
file_extension = Path(urlparse(url).path).suffix
with (Path(menu_dir) / f"jupyterlab{file_extension}").open("wb") as icon_file:
LOGGER.debug(f"Writing icon file to {icon_file.name}")
icon_file.write(response.content)
if platform.system() == "Darwin" and file_extension == ".svg":
svg_to_icns(menu_dir/"jupyterlab.svg")
def svg_to_icns(svg_file: PathLike) -> None:
if not platform.system() == "Darwin":
raise RuntimeError("svg_to_icns is designed to run on MacOS only")
svg_file = Path(svg_file)
if not svg_file.exists():
raise ValueError(f"{svg_file} does not exist")
elif not svg_file.is_file():
raise ValueError(f"{svg_file} exists but is not a file")
elif not svg_file.suffix == ".svg":
raise ValueError(f"{svg_file} doesn't seem to be an SVG file")
target_dir = svg_file.parent
LOGGER.debug(f"Converting {svg_file} to {svg_file.with_suffix('.icns')}")
with TemporaryDirectory() as temp_dir:
iconset_dir = Path(temp_dir)/"jupyterlab.iconset"
iconset_dir.mkdir(exist_ok=True)
for size in [2**n for n in range(4, 11)]:
outfile = iconset_dir/f"icon_{size}x{size}.png"
try:
run(
[
"qlmanage",
"-t",
"-s",
str(size),
"-o",
str(iconset_dir),
str(svg_file)
],
capture_output=True,
check=True
)
except CalledProcessError as e:
LOGGER.critical(f"Error converting SVG icon to PNG thumbnail iconset with qlmanage")
raise
(iconset_dir/svg_file.with_suffix(".svg.png").name).rename(outfile)
if size > 16:
copy(outfile, outfile.with_stem(f"icon_{size/2}x{size/2}@2x"))
try:
run(
[
"iconutil",
"-c",
"icns",
"-o",
str(target_dir/"jupyterlab.icns"),
str(iconset_dir)
],
capture_output=True,
check=True
)
except CalledProcessError as e:
LOGGER.critical(f"Error calling iconutil to create MacOS .icns file from iconset in {iconset_dir}")
raise
svg_file.unlink()
def ensure_env(env_name: str) -> Path:
conda_proc = run(
[
"conda",
"list",
"--json",
"--name",
env_name
],
capture_output=True,
check=False
)
try:
env_spec = loads(conda_proc.stdout)
except JSONDecodeError as e:
raise RuntimeError(f"Error parsing conda output while checking the existance of '{env_name}'") from e
else:
env_prefix = get_base_prefix()/f"envs/{env_name}"
if isinstance(env_spec, dict) and "error" in env_spec.keys():
# enviornment doesn't exist. create one with some commonly used packages
# including ipywidgets because it will automatically install the plugin to render them in jupyterlab
# ultimately, though, nb_conda_kernels allows the execution of notebooks in any installed conda environment
# which has `ipykernel` installed
LOGGER.info(f"Creating new conda enviornment '{env_name}'")
run(
[
"conda",
"create",
"--yes",
"--name",
env_name,
"--override-channels",
"--channel",
"conda-forge",
"--no-default-packages",
"jupyterlab",
"nb_conda_kernels",
"nbconvert",
"jupyterlab-nbconvert-nocode",
"jupyterlab-git",
"jupyterlab-lsp",
"jupyterlab_code_formatter",
"jupyterlab-day",
"jupyterlab-night",
"jupyterlab_pygments",
"black",
"isort",
"python-lsp-server",
"ipykernel",
"ipywidgets",
"panel",
"pandas",
"numpy",
"scipy",
"statsmodels",
"openpyxl",
"tabulate",
"matplotlib",
"toolz",
"more-itertools",
"ipyparallel",
"requests",
"tqdm",
"rich",
"rich-with-jupyter",
"sqlalchemy",
],
capture_output=False,
check=True
)
# set conda-forge as the one and only channel for this environment
with open(env_prefix/".condarc", "w") as condarc:
print(
"pip_interop_enabled: true",
"channels: [conda-forge]",
sep=linesep,
end=linesep,
file=condarc
)
else:
# add minimal new jupyter packages t existing environment
pkgs = {pkg["name"] for pkg in env_spec}
missing_pkgs = {"jupyterlab", "nb_conda_kernels", "ipykernel"} - pkgs
if missing_pkgs:
LOGGER.info(f"Installing {'and '.join(missing_pkgs)} in '{env_name}")
run(
[
"conda",
"install",
"--yes",
"--name",
env_name,
*sorted(missing_pkgs)
],
capture_output=False,
check=True
)
(env_prefix/"Menu").mkdir(exist_ok=True)
return env_prefix
def main(target_env_name: str, remove_shortcut: bool=False) -> Union[int, None]:
if not in_base_env():
LOGGER.warning("Not in base environment. Re-running this script from the base environment")
# call conda run to re-run this in the base prefix
rerun_proces = run(
["conda", "run", "--prefix", str(get_base_prefix()), "--no-capture-output", "python", *argv],
capture_output=False,
check=False
)
# Surface subprocess return code to parent process
return rerun_proces.returncode
elif not menuinst_gt_v2_present():
if input("This script requires menuinst>=2.0.0. Would you like to install it and try again? ").lower() in ("y", "yes"):
run(
["conda", "install", "--prefix", str(get_base_prefix()), "menuinst>=2.0.0"],
capture_output=False,
check=True
)
run(["python", *argv], capture_output=False, check=True)
elif meets_prerequisites() and remove_shortcut:
from menuinst.api import remove
target_prefix = get_base_prefix() / f"envs/{target_env_name}"
menu_dir = target_prefix / "Menu"
shortcut_json = menu_dir / "jupyterlab_shortcut.json"
jupyterlab_config = menu_dir / "jupyter_lab_config.py"
match platform.system():
case "Windows":
icon_file = menu_dir/"jupyterlab.ico"
case "Linux":
icon_file = menu_dir/"jupyterlab.png"
case "Darwin":
icon_file = menu_dir/"jupyterlab.ico"
case _:
raise RuntimeError(f"{platform.system()} is not a supported platform")
if shortcut_json.is_file():
LOGGER.debug("Removing JupyterLab shortcut")
try:
remove(shortcut_json, target_prefix=target_prefix)
except:
pass
else:
LOGGER.info("JupyterLab shortcut removed")
else:
LOGGER.error(f"Shortcut spec file '{shortcut_json}' does not exist, therefore the shotcut cannot be removed by this script. Please delete it manually")
if menu_dir.exists() and menu_dir.is_dir():
LOGGER.debug(f"Cleaning up {target_env_name} environment's Menu directory {menu_dir}")
jupyterlab_config.unlink(missing_ok=True)
icon_file.unlink(missing_ok=True)
shortcut_json.unlink(missing_ok=True)
elif meets_prerequisites():
from menuinst.api import install, remove
target_prefix = ensure_env(target_env_name)
shortcut_json = stage_configs(target_prefix/"Menu")
try:
LOGGER.debug("Removing old shortcut")
remove(shortcut_json, target_prefix=target_prefix)
except:
pass
finally:
LOGGER.debug("Creating new shortcut")
install(shortcut_json, target_prefix=target_prefix)
LOGGER.info("Jupyter Lab shortcut created!")
if __name__ == "__main__":
LOGGER = logging.getLogger()
STDOUT = logging.StreamHandler(stdout)
STDOUT.setLevel(logging.INFO)
STDOUT.addFilter(lambda r: r.levelno < logging.WARNING)
STDOUT.setFormatter(logging.Formatter())
STDERR = logging.StreamHandler(stderr)
STDERR.setLevel(logging.WARNING)
STDERR.setFormatter(
logging.Formatter(
"{levelname}: {message}",
style="{"
)
)
DEBUG_HANDLER = logging.StreamHandler(stderr)
DEBUG_HANDLER.setLevel(logging.DEBUG)
DEBUG_HANDLER.addFilter(lambda r: r.levelno < logging.INFO)
DEBUG_HANDLER.setFormatter(
logging.Formatter(
"{levelname}: {message}",
style="{"
)
)
parser = ArgumentParser()
parser.add_argument(
"--debug",
action = "store_true",
help = "Enable debug logging to the terminal"
)
parser.add_argument(
"--remove",
action = "store_true",
help = "Remove the shortcut created by this script and cleanup environment Menu directory to remove files created by this script"
)
parser.add_argument(
"name",
help = "Name of the target conda environment containing JupyterLab. If it does not exist, one will be created"
)
args = parser.parse_args()
if args.debug:
logging.basicConfig(
level=logging.DEBUG,
handlers=(STDOUT, STDERR, DEBUG_HANDLER),
)
else:
logging.basicConfig(
level=logging.INFO,
handlers=(STDOUT, STDERR),
)
logging.captureWarnings(True)
try:
exit(main(args.name, args.remove))
except CalledProcessError as e:
# catch any exceptions raised if calling conda returns an unsuccessful return code
# log exception and suface conda's return code.
logging.exception(f"'{e.cmd}' failed with exit code {e.returncode}:{linesep * 2}{e. output}")
exit(e.returncode)
except (OperationCancelled, KeyboardInterrupt) as e:
logging.info("Operation cancelled by the user")
exit(1)
except Exception as e:
logging.exception(str(e))
exit(1)
finally:
logging.shutdown()