initial-dev, ready to test
This commit is contained in:
@@ -10,5 +10,5 @@ Adds a shortcut for running Jupyter Lab in a Chromium-based browser's "app" mode
|
||||
## 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 active in your `base` conda environment (or whichever environment conda is installed in), execute `python ./setup_jupyter.py your_jupyter_env_name_here`
|
||||
|
||||
|
||||
50
jupyter_lab_config.py
Normal file
50
jupyter_lab_config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import platform
|
||||
|
||||
from itertools import product
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
|
||||
# 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())
|
||||
|
||||
# find a chromium-based browser on the system to use.
|
||||
if platform.system() == "Windows":
|
||||
# prefers Microsoft Edge on Windows
|
||||
cmds = (
|
||||
"Microsoft/Edge/Application/msedge.exe",
|
||||
"Google/Chrome/Application/chrome.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")
|
||||
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():
|
||||
c.ServerApp.browser = (
|
||||
f'"{browser}" --start-maximized --profile-directory=Default --app=%s'
|
||||
)
|
||||
break
|
||||
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
56
jupyterlab_shortcut.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
413
setup_jupyter.py
Normal file
413
setup_jupyter.py
Normal file
@@ -0,0 +1,413 @@
|
||||
#!/bin/env python
|
||||
import logging
|
||||
import platform
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from json import JSONDecodeError, loads
|
||||
from os import environ, linesep
|
||||
from os.path import Pathlike
|
||||
from pathlib import Path
|
||||
from shutil import copy
|
||||
from subprocess import CalledProcessError, run
|
||||
from sys import argv, stderr, stdout
|
||||
from tempfile import TemporaryDirectory
|
||||
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"], 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_extension.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=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 "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) -> int:
|
||||
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", __file__, *argv[1:]],
|
||||
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", __file__, *argv[1:]], capture_output=False, check=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()
|
||||
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action = "store_true",
|
||||
description = "Enable debug logging to the terminal"
|
||||
)
|
||||
parser.add_argument(
|
||||
"name",
|
||||
default="jupyter",
|
||||
description = "Name of the target conda environment containing JupyterLab. If it does not exist, one will be created"
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
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))
|
||||
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()
|
||||
Reference in New Issue
Block a user