Skip to content

Commit

Permalink
feature: add jupyterlite files
Browse files Browse the repository at this point in the history
todo: add rests
  • Loading branch information
timurbazhirov committed Dec 13, 2024
1 parent 08ab340 commit d5223a9
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 0 deletions.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ extra = [
"tabulate",
"jinja2",
]
jupyterlite = [
"pyyaml",
]
dev = [
"pre-commit",
"black",
Expand Down
Empty file.
12 changes: 12 additions & 0 deletions src/py/mat3ra/utils/jupyterlite/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from enum import Enum


class EnvironmentsEnum(Enum):
PYODIDE = "pyodide"
PYTHON = "python"


class SeverityLevelEnum(Enum):
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
34 changes: 34 additions & 0 deletions src/py/mat3ra/utils/jupyterlite/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import os
import inspect
from typing import Optional

from .enums import SeverityLevelEnum


def log(message: str, level: Optional[SeverityLevelEnum] = None, force_verbose: Optional[bool] = None):
"""
Log a message based on the VERBOSE flag in the caller's globals().
Args:
message (str): The message to log.
level (SeverityLevelEnum, optional): The severity level of the message (e.g., INFO, WARNING, ERROR).
force_verbose (bool, optional): If True, log the message regardless of the VERBOSE flag in globals().
"""
if force_verbose is True:
should_log = True
elif force_verbose is False:
should_log = False
else:
# Inspect the caller's globals to get VERBOSE flag
frame = inspect.currentframe()
try:
caller_frame = frame.f_back # type: ignore
caller_globals = caller_frame.f_globals # type: ignore
should_log = caller_globals.get("VERBOSE", os.environ.get("VERBOSE", True))
finally:
del frame # Avoid reference cycles
if should_log:
if level is None:
print(message)
else:
print(f"{level.value}: {message}")
160 changes: 160 additions & 0 deletions src/py/mat3ra/utils/jupyterlite/packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import json
import os
import re
import sys
from typing import List
from .enums import EnvironmentsEnum
from .logger import log

# default value for env.HOME from https://pyodide.org/en/stable/usage/api/js-api.html
ENVIRONMENT = EnvironmentsEnum.PYODIDE if os.environ.get("HOME") == "/home/pyodide" else EnvironmentsEnum.PYTHON

if ENVIRONMENT == EnvironmentsEnum.PYODIDE:
import micropip
elif ENVIRONMENT == EnvironmentsEnum.PYTHON:
import subprocess


async def install_init():
if sys.platform == "emscripten":
import micropip
for package in ["pyyaml"]:
await micropip.install(package)


async def install_package_pyodide(pkg: str, verbose: bool = True):
"""
Install a package in a Pyodide environment.
Args:
pkg (str): The name of the package to install.
verbose (bool): Whether to print the name of the installed package.
"""
is_url = pkg.startswith("http://") or pkg.startswith("https://") or pkg.startswith("emfs:/")
are_dependencies_installed = not is_url
await micropip.install(pkg, deps=are_dependencies_installed)
pkg_name = pkg.split("/")[-1].split("-")[0] if is_url else pkg.split("==")[0]
if verbose:
log(f"Installed {pkg_name}", force_verbose=verbose)


def install_package_python(pkg: str, verbose: bool = True):
"""
Install a package in a standard Python environment.
Args:
pkg (str): The name of the package to install.
verbose (bool): Whether to print the name of the installed package.
"""
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
if verbose:
log(f"Installed {pkg}", force_verbose=verbose)


async def install_package(pkg: str, verbose: bool = True):
"""
Install a package in the current environment.
Args:
pkg (str): The name of the package to install.
verbose (bool): Whether to print the name of the installed package.
"""
if ENVIRONMENT == EnvironmentsEnum.PYODIDE:
await install_package_pyodide(pkg, verbose)
elif ENVIRONMENT == EnvironmentsEnum.PYTHON:
install_package_python(pkg, verbose)


def get_config_yml_file_path(config_file_path) -> str:
"""
Get the path to the requirements file.
Returns:
str: The path to the requirements file.
"""
base_path = os.getcwd()
config_file_full_path = os.path.normpath(os.path.join("/drive/", "./config.yml"))
if config_file_path != "":
config_file_full_path = os.path.normpath(os.path.join(base_path, config_file_path))
return config_file_full_path


def get_packages_list(requirements_dict: dict, notebook_name_pattern: str = "") -> List[str]:
"""
Get the list of packages to install based on the requirements file.
Args:
requirements_dict (dict): The dictionary containing the requirements.
notebook_name_pattern (str): The pattern of the notebook name.
Returns:
List[str]: The list of packages to install.
"""
packages_default_common = requirements_dict.get("default", {}).get("packages_common", [])
packages_default_environment_specific = (
requirements_dict.get("default", {}).get(f"packages_{ENVIRONMENT.value}", [])
)

matching_notebook_requirements_list = [cfg for cfg in requirements_dict.get("notebooks", []) if
re.search(cfg.get("name"), notebook_name_pattern)]
packages_notebook_common = []
packages_notebook_environment_specific = []

for notebook_requirements in matching_notebook_requirements_list:
packages_common = notebook_requirements.get("packages_common", [])
packages_environment_specific = notebook_requirements.get(f"packages_{ENVIRONMENT.value}", [])
if packages_common:
packages_notebook_common.extend(packages_common)
if packages_environment_specific:
packages_notebook_environment_specific.extend(packages_environment_specific)

# Note: environment specific packages have to be installed first,
# because in Pyodide common packages might depend on them
packages = [
*packages_default_environment_specific,
*packages_notebook_environment_specific,
*packages_default_common,
*packages_notebook_common,
]
return packages


async def install_packages_with_hashing(packages: List[str], verbose: bool = True):
"""
Install the packages listed in the requirements file for the notebook with the given name.
Args:
notebook_name_pattern (str): The name pattern of the notebook for which to install packages.
config_file_path (str): The path to the requirements file.
verbose (bool): Whether to print the names of the installed packages and status of installation.
"""
# Hash the requirements to avoid re-installing packages
requirements_hash = str(hash(json.dumps(packages)))
if os.environ.get("requirements_hash") != requirements_hash:
for pkg in packages:
await install_package(pkg, verbose)
if verbose:
log("Packages installed successfully.", force_verbose=verbose)
os.environ["requirements_hash"] = requirements_hash
else:
if verbose:
log("Packages are already installed.", force_verbose=verbose)


async def install_packages(notebook_name_pattern: str, config_file_path: str = "", verbose: bool = True):
"""
Install the packages listed in the requirements file for the notebook with the given name.
Args:
notebook_name_pattern (str): The name pattern of the notebook for which to install packages.
config_file_path (str): The path to the requirements file.
verbose (bool): Whether to print the names of the installed packages and status of installation.
"""
if ENVIRONMENT == EnvironmentsEnum.PYODIDE:
await install_init()
# PyYAML has to be installed before being imported in Pyodide and can't appear at the top of the file
import yaml
with open(get_config_yml_file_path(config_file_path), "r") as f:
requirements_dict = yaml.safe_load(f)
packages = get_packages_list(requirements_dict, notebook_name_pattern)
await install_packages_with_hashing(packages, verbose)
1 change: 1 addition & 0 deletions src/py/mat3ra/utils/jupyterlite/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
UPLOADS_FOLDER = "uploads"

0 comments on commit d5223a9

Please sign in to comment.