diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 80b81128..235f4fc0 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -5,6 +5,7 @@ import hashlib import io import json +import mimetypes import os import subprocess import sys @@ -15,6 +16,8 @@ from collections import defaultdict from mimetypes import guess_type from pathlib import Path +from copy import deepcopy +from typing import List import click @@ -50,6 +53,220 @@ ] directories_to_ignore = {Path(d) for d in directories_ignore_list} +mimetypes.add_type("text/ipynb", ".ipynb") + + +class Manifest: + def __init__(self, *args, **kwargs) -> None: + self.data = dict() + self.buffer: dict = dict() + self._deploy_dir = None + version = kwargs.get("version") + environment = kwargs.get("environment") + app_mode = kwargs.get("app_mode") + entrypoint = kwargs.get("entrypoint") + quarto_inspection = kwargs.get("quarto_inspection") + environment = kwargs.get("environment") + image = kwargs.get("image") + + self.data["version"] = version if version else 1 + if environment: + self.data["locale"] = environment.locale + + self.data["metadata"] = ( + { + "appmode": app_mode.name(), + } + if app_mode + else { + "appmode": AppModes.UNKNOWN, + } + ) + + if entrypoint: + self.data["metadata"]["entrypoint"] = entrypoint + + if quarto_inspection: + self.data["quarto"] = { + "version": quarto_inspection.get("quarto", {}).get("version", "99.9.9"), + "engines": quarto_inspection.get("engines", []), + } + project_config = quarto_inspection.get("config", {}).get("project", {}) + render_targets = project_config.get("render", []) + if len(render_targets): + self.data["metadata"]["primary_rmd"] = render_targets[0] + project_type = project_config.get("type", None) + if project_type or len(render_targets) > 1: + self.data["metadata"]["content_category"] = "site" + + if environment: + package_manager = environment.package_manager + self.data["python"] = { + "version": environment.python, + "package_manager": { + "name": package_manager, + "version": getattr(environment, package_manager), + "package_file": environment.filename, + }, + } + + if image: + self.data["environment"] = { + "image": image, + } + + self.data["files"] = {} + + @property + def deploy_dir(self): + return self._deploy_dir + + @deploy_dir.setter + def deploy_dir(self, value): + self._deploy_dir = value + + @classmethod + def from_json(cls, json_str): + return cls(json.loads(json_str)) + + @classmethod + def from_json_file(cls, json_path): + with open(json_path) as json_file: + return cls(json.load(json_file)) + + @property + def json(self): + return json.dumps(self.data, indent=2) + + @property + def entrypoint(self): + if "metadata" not in self.data: + return None + if "entrypoint" in self.data["metadata"]: + return self.data["metadata"]["entrypoint"] + return None + + @entrypoint.setter + def entrypoint(self, value): + self.data["metadata"]["entrypoint"] = value + + def add_file(self, path): + self.data["files"][path] = {"checksum": file_checksum(path)} + return self + + def add_relative_path(self, path): + """ + Assumes that path resides below the deployment directory, construct that path add it to the manifest. + """ + deploy_dir = dirname(self.entrypoint) if isfile(self.entrypoint) else self.entrypoint + mod_path = join(deploy_dir, path) + self.data["files"][mod_path] = {"checksum": file_checksum(mod_path)} + return self + + def discard_file(self, path): + if path in self.data["files"]: + del self.data["files"][path] + return self + + def add_to_buffer(self, key, value): + self.buffer[key] = value + self.data["files"][key] = {"checksum": buffer_checksum(value)} + return self + + def discard_from_buffer(self, key): + if key in self.buffer: + del self.buffer[key] + del self.data["files"][key] + return self + + @property + def flattened_data(self): + if self.entrypoint is None: + raise RSConnectException("A valid entrypoint must be provided.") + new_data_files = {} + deploy_dir = dirname(self.entrypoint) if isfile(self.entrypoint) else self.entrypoint + for path in self.data["files"]: + rel_path = relpath(path, deploy_dir) + new_data_files[rel_path] = self.data["files"][path] + return new_data_files + + @property + def flattened_buffer(self): + if self.entrypoint is None: + raise RSConnectException("A valid entrypoint must be provided.") + new_buffer = {} + deploy_dir = dirname(self.entrypoint) if isfile(self.entrypoint) else self.entrypoint + for k, v in self.buffer.items(): + rel_path = relpath(k, deploy_dir) + new_buffer[rel_path] = v + return new_buffer + + @property + def flattened_entrypoint(self): + if self.entrypoint is None: + raise RSConnectException("A valid entrypoint must be provided.") + return relpath(self.entrypoint, dirname(self.entrypoint)) + + @property + def flattened_copy(self): + if self.entrypoint is None: + raise RSConnectException("A valid entrypoint must be provided.") + new_manifest = deepcopy(self) + new_manifest.data["files"] = self.flattened_data + new_manifest.buffer = self.flattened_buffer + new_manifest.entrypoint = self.flattened_entrypoint + return new_manifest + + def make_relative_to_deploy_dir(self): + self = self.flattened_copy + return self + + +class Bundle: + def __init__(self, *args, **kwargs) -> None: + self.file_paths: set = set() + self.buffer: dict = {} + self._deploy_dir = None + + @property + def deploy_dir(self): + return self._deploy_dir + + @deploy_dir.setter + def deploy_dir(self, value): + self._deploy_dir = value + + def add_file(self, filepath): + self.file_paths.add(filepath) + + def discard_file(self, filepath): + self.file_paths.discard(filepath) + + def to_file(self, flatten_to_deploy_dir=True): + bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") + with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle: + for fp in self.file_paths: + if Path(fp).name in self.buffer: + continue + rel_path = Path(fp).relative_to(self.deploy_dir) if flatten_to_deploy_dir else None + bundle.add(fp, arcname=rel_path) + for k, v in self.buffer.items(): + buf = io.BytesIO(to_bytes(v)) + file_info = tarfile.TarInfo(k) + file_info.size = len(buf.getvalue()) + bundle.addfile(file_info, buf) + bundle_file.seek(0) + return bundle_file + + def add_to_buffer(self, key, value): + self.buffer[key] = value + return self + + def discard_from_buffer(self, key): + if key in self.buffer: + del self.buffer[key] + return self + # noinspection SpellCheckingInspection def make_source_manifest( @@ -418,7 +635,7 @@ def make_notebook_html_bundle( # manifest manifest = make_html_manifest(filename, image) - bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest)) + bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest, indent=2)) # rewind file pointer bundle_file.seek(0) @@ -673,15 +890,17 @@ def create_file_list( file_set = set(extra_files) # type: typing.Set[str] if isfile(path): - file_set.add(path) + file_set.add(Path(path).name) return sorted(file_set) - for subdir, dirs, files in os.walk(path): - if Path(subdir) in exclude_paths: + for cur_dir, sub_dirs, files in os.walk(path): + if Path(cur_dir) in exclude_paths: + continue + if any(parent in exclude_paths for parent in Path(cur_dir).parents): continue for file in files: - abs_path = os.path.join(subdir, file) - rel_path = os.path.relpath(abs_path, path) + abs_path = os.path.join(cur_dir, file) + rel_path = relpath(abs_path, path) if Path(abs_path) in exclude_paths: continue @@ -693,25 +912,31 @@ def create_file_list( def infer_entrypoint(path, mimetype): - if os.path.isfile(path): - return path - if not os.path.isdir(path): - raise ValueError("Entrypoint is not a valid file type or directory.") + candidates = infer_entrypoint_candidates(path, mimetype) + return candidates.pop() if len(candidates) == 1 else None - default_mimetype_entrypoints = {"text/html": "index.html"} - if mimetype not in default_mimetype_entrypoints: - raise ValueError("Not supported mimetype inference.") + +def infer_entrypoint_candidates(path, mimetype) -> List: + if not path: + return [] + if isfile(path): + return [path] + if not isdir(path): + return [] + + default_mimetype_entrypoints = defaultdict(str) + default_mimetype_entrypoints["text/html"] = "index.html" mimetype_filelist = defaultdict(list) for file in os.listdir(path): rel_path = os.path.join(path, file) - if not os.path.isfile(rel_path): + if not isfile(rel_path): continue mimetype_filelist[guess_type(file)[0]].append(rel_path) if file in default_mimetype_entrypoints[mimetype]: return file - return mimetype_filelist[mimetype].pop() if len(mimetype_filelist[mimetype]) == 1 else None + return mimetype_filelist[mimetype] or [] def make_html_bundle( @@ -746,6 +971,95 @@ def make_html_bundle( return bundle_file +def guess_deploy_dir(path, entrypoint): + abs_path = abspath(path) if path else None + abs_entrypoint = abspath(entrypoint) if entrypoint else None + if not path and not entrypoint: + raise RSConnectException("No path or entrypoint provided.") + deploy_dir = None + if path and isfile(path): + if not entrypoint: + deploy_dir = dirname(abs_path) + elif isfile(entrypoint) and abs_path != abs_entrypoint: + raise RSConnectException("Path and entrypoint need to match if they are both files.") + elif isfile(entrypoint) and abs_path == abs_entrypoint: + deploy_dir = dirname(abs_path) + elif isdir(entrypoint): + raise RSConnectException("Entrypoint cannot be a directory while the path is a file.") + elif path and isdir(path): + if not entrypoint: + deploy_dir = abs_path + elif entrypoint and isdir(entrypoint): + raise RSConnectException("Path and entrypoint cannot both be directories.") + elif entrypoint: + guess_entry_file = os.path.join(abs_path, basename(entrypoint)) + if isfile(guess_entry_file): + deploy_dir = dirname(guess_entry_file) + elif isfile(entrypoint): + deploy_dir = dirname(abs_entrypoint) + elif not path and entrypoint: + raise RSConnectException("A path needs to be provided.") + else: + deploy_dir = abs_path + return deploy_dir + + +def abs_entrypoint(path, entrypoint): + if isfile(entrypoint): + return abspath(entrypoint) + guess_entry_file = os.path.join(abspath(path), basename(entrypoint)) + if isfile(guess_entry_file): + return guess_entry_file + return None + + +def make_voila_bundle( + path: str, + entrypoint: str, + extra_files: typing.List[str], + excludes: typing.List[str], + force_generate: bool, + environment: Environment, + image: str = None, + multi_notebook: bool = False, +) -> typing.IO[bytes]: + """ + Create an voila bundle, given a path and/or entrypoint. + + The bundle contains a manifest.json file created for the given notebook entrypoint file. + If the related environment file (requirements.txt) doesn't + exist (or force_generate is set to True), the environment file will also be written. + + :param path: the file, or the directory containing the files to deploy. + :param entry_point: the main entry point. + :param extra_files: a sequence of any extra files to include in the bundle. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param force_generate: bool indicating whether to force generate manifest and related environment files. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: a file-like object containing the bundle tarball. + """ + + manifest = create_voila_manifest(**locals()) + if manifest.data.get("files") is None: + raise RSConnectException("No valid files were found for the manifest.") + + bundle = Bundle() + for f in manifest.data["files"]: + if f in manifest.buffer: + continue + bundle.add_file(f) + for k, v in manifest.flattened_buffer.items(): + bundle.add_to_buffer(k, v) + + manifest_flattened_copy_data = manifest.flattened_copy.data + if multi_notebook and "metadata" in manifest_flattened_copy_data: + manifest_flattened_copy_data["metadata"]["entrypoint"] = "" + bundle.add_to_buffer("manifest.json", json.dumps(manifest_flattened_copy_data, indent=2)) + bundle.deploy_dir = manifest.deploy_dir + + return bundle.to_file() + + def make_api_bundle( directory: str, entry_point: str, @@ -991,6 +1305,14 @@ def validate_entry_point(entry_point, directory): return entry_point +def _warn_on_ignored_entrypoint(entrypoint): + if entrypoint: + click.secho( + " Warning: entrypoint will not be used or considered for multi-notebook mode.", + fg="yellow", + ) + + def _warn_on_ignored_manifest(directory): """ Checks for the existence of a file called manifest.json in the given directory. @@ -1245,6 +1567,127 @@ def write_notebook_manifest_json( return exists(join(directory, environment.filename)) +MULTI_NOTEBOOK_EXC_MSG = """ +Unable to infer entrypoint. +Multi-notebook deployments need to be specified with the following: +1) A directory as the path +2) Set multi_notebook=True, + i.e. include --multi-notebook (or -m) in the CLI command. +""" + + +def create_voila_manifest( + path: str, + entrypoint: str, + environment: Environment, + app_mode: AppMode = AppModes.JUPYTER_VOILA, + extra_files: typing.List[str] = None, + excludes: typing.List[str] = None, + force_generate: bool = True, + image: str = None, + multi_notebook: bool = False, + **kwargs +) -> Manifest: + """ + Creates and writes a manifest.json file for the given path. + + :param path: the file, or the directory containing the files to deploy. + :param entry_point: the main entry point for the API. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. If this is None, the extension + portion of the entry point file name will be used to derive one. Previous default = None. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param force_generate: bool indicating whether to force generate manifest and related environment files. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: the manifest data structure. + """ + if not path: + raise RSConnectException("A valid path must be provided.") + extra_files = list(extra_files) if extra_files else [] + entrypoint_candidates = infer_entrypoint_candidates(path=abspath(path), mimetype="text/ipynb") + + deploy_dir = guess_deploy_dir(path, entrypoint) + if not multi_notebook: + if len(entrypoint_candidates) <= 0: + if entrypoint is None: + raise RSConnectException(MULTI_NOTEBOOK_EXC_MSG) + entrypoint = abs_entrypoint(path, entrypoint) + elif len(entrypoint_candidates) == 1: + if entrypoint: + entrypoint = abs_entrypoint(path, entrypoint) + else: + entrypoint = entrypoint_candidates[0] + else: # len(entrypoint_candidates) > 1: + if entrypoint is None: + raise RSConnectException(MULTI_NOTEBOOK_EXC_MSG) + entrypoint = abs_entrypoint(path, entrypoint) + + if multi_notebook: + if path and not isdir(path): + raise RSConnectException(MULTI_NOTEBOOK_EXC_MSG) + _warn_on_ignored_entrypoint(entrypoint) + deploy_dir = entrypoint = abspath(path) + extra_files = validate_extra_files(deploy_dir, extra_files) + excludes = list(excludes) if excludes else [] + excludes.extend([environment.filename, "manifest.json"]) + excludes.extend(list_environment_dirs(deploy_dir)) + + voila_json_path = join(deploy_dir, "voila.json") + if isfile(voila_json_path): + extra_files.append(voila_json_path) + + manifest = Manifest(app_mode=AppModes.JUPYTER_VOILA, environment=environment, entrypoint=entrypoint, image=image) + manifest.deploy_dir = deploy_dir + if entrypoint and isfile(entrypoint): + validate_file_is_notebook(entrypoint) + manifest.entrypoint = entrypoint + + manifest.add_to_buffer(join(deploy_dir, environment.filename), environment.contents) + + file_list = create_file_list(path, extra_files, excludes) + for rel_path in file_list: + manifest.add_relative_path(rel_path) + return manifest + + +def write_voila_manifest_json( + path: str, + entrypoint: str, + environment: Environment, + app_mode: AppMode = AppModes.JUPYTER_VOILA, + extra_files: typing.List[str] = None, + excludes: typing.List[str] = None, + force_generate: bool = True, + image: str = None, + multi_notebook: bool = False, +) -> bool: + """ + Creates and writes a manifest.json file for the given path. + + :param path: the file, or the directory containing the files to deploy. + :param entry_point: the main entry point for the API. + :param environment: the Python environment to start with. This should be what's + returned by the inspect_environment() function. + :param app_mode: the application mode to assume. If this is None, the extension + portion of the entry point file name will be used to derive one. Previous default = None. + :param extra_files: any extra files that should be included in the manifest. Previous default = None. + :param excludes: a sequence of glob patterns that will exclude matched files. + :param force_generate: bool indicating whether to force generate manifest and related environment files. + :param image: the optional docker image to be specified for off-host execution. Default = None. + :return: whether the manifest was written. + """ + manifest = create_voila_manifest(**locals()) + deploy_dir = dirname(manifest.entrypoint) if isfile(manifest.entrypoint) else manifest.entrypoint + manifest_flattened_copy_data = manifest.flattened_copy.data + if multi_notebook and "metadata" in manifest_flattened_copy_data: + manifest_flattened_copy_data["metadata"]["entrypoint"] = "" + manifest_path = join(deploy_dir, "manifest.json") + write_manifest_json(manifest_path, manifest_flattened_copy_data) + return exists(manifest_path) + + def create_api_manifest_and_environment_file( directory: str, entry_point: str, diff --git a/rsconnect/main.py b/rsconnect/main.py index 6d6c1b38..b4f1f7b6 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -49,11 +49,13 @@ make_api_bundle, make_notebook_html_bundle, make_notebook_source_bundle, + make_voila_bundle, read_manifest_app_mode, write_notebook_manifest_json, write_api_manifest_json, write_environment_file, write_quarto_manifest_json, + write_voila_manifest_json, validate_entry_point, validate_extra_files, validate_file_is_notebook, @@ -842,6 +844,108 @@ def deploy_notebook( ce.deploy_bundle().save_deployed_info().emit_task_log() +# noinspection SpellCheckingInspection,DuplicatedCode +@deploy.command( + name="voila", + short_help="Deploy Jupyter notebook in Voila mode to RStudio Connect [v2023.03.0+].", + help=("Deploy a Jupyter notebook in Voila mode to RStudio Connect."), + no_args_is_help=True, +) +@server_args +@content_args +@click.option( + "--entrypoint", + "-e", + help=("The module and executable object which serves as the entry point."), +) +@click.option( + "--multi-notebook", + "-m", + is_flag=True, + help=("Deploy in multi-notebook mode."), +) +@click.option( + "--exclude", + "-x", + multiple=True, + help=( + "Specify a glob pattern for ignoring files when building the bundle. Note that your shell may try " + "to expand this which will not do what you expect. Generally, it's safest to quote the pattern. " + "This option may be repeated." + ), +) +@click.option( + "--python", + "-p", + type=click.Path(exists=True), + help=( + "Path to Python interpreter whose environment should be used. " + "The Python environment must have the rsconnect package installed." + ), +) +@click.option( + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', +) +@click.option( + "--image", + "-I", + help="Target image to be used during content execution (only applicable if the RStudio Connect " + "server is configured to use off-host execution)", +) +@click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True)) +@click.argument( + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), +) +@cli_exception_handler +def deploy_voila( + path: str = None, + entrypoint: str = None, + python=None, + force_generate=False, + extra_files=None, + exclude=None, + image: str = "", + title: str = None, + env_vars: typing.Dict[str, str] = None, + verbose: bool = False, + new: bool = False, + app_id: str = None, + name: str = None, + server: str = None, + api_key: str = None, + insecure: bool = False, + cacert: typing.IO = None, + connect_server: api.RSConnectServer = None, + multi_notebook: bool = False, +): + kwargs = locals() + set_verbosity(verbose) + app_mode = AppModes.JUPYTER_VOILA + kwargs["extra_files"] = extra_files = validate_extra_files(dirname(path), extra_files) + environment = create_python_environment( + path if isdir(path) else dirname(path), + force_generate, + python, + ) + ce = RSConnectExecutor(**kwargs).validate_server().validate_app_mode(app_mode=app_mode) + ce.make_bundle( + make_voila_bundle, + path, + entrypoint, + extra_files, + exclude, + force_generate, + environment, + image=image, + multi_notebook=multi_notebook, + ).deploy_bundle().save_deployed_info().emit_task_log() + + # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="manifest", @@ -1358,6 +1462,110 @@ def write_manifest_notebook( write_environment_file(environment, base_dir) +@write_manifest.command( + name="voila", + short_help="Create a manifest.json file for a Voila notebook.", + help=( + "Create a manifest.json file for a Voila notebook for later deployment. " + 'This will create an environment file ("requirements.txt") if one does ' + "not exist. All files are created in the same directory as the notebook file." + ), +) +@click.option("--overwrite", "-o", is_flag=True, help="Overwrite manifest.json, if it exists.") +@click.option( + "--python", + "-p", + type=click.Path(exists=True), + help="Path to Python interpreter whose environment should be used. " + + "The Python environment must have the rsconnect package installed.", +) +@click.option( + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', +) +@click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") +@click.option( + "--image", + "-I", + help="Target image to be used during content execution (only applicable if the RStudio Connect " + "server is configured to use off-host execution)", +) +@click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True)) +@click.argument( + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), +) +@click.option("--entrypoint", "-e", help=("The module and executable object which serves as the entry point.")) +@click.option( + "--exclude", + "-x", + multiple=True, + help=( + "Specify a glob pattern for ignoring files when building the bundle. Note that your shell may try " + "to expand this which will not do what you expect. Generally, it's safest to quote the pattern. " + "This option may be repeated." + ), +) +@click.option( + "--multi-notebook", + "-m", + is_flag=True, + help=("Set the manifest for multi-notebook mode."), +) +def write_manifest_voila( + path: str, + entrypoint: str, + overwrite, + python, + force_generate, + verbose, + extra_files, + exclude, + image, + multi_notebook, +): + set_verbosity(verbose) + with cli_feedback("Checking arguments"): + base_dir = dirname(path) + extra_files = validate_extra_files(base_dir, extra_files) + manifest_path = join(base_dir, "manifest.json") + + if exists(manifest_path) and not overwrite: + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + + with cli_feedback("Inspecting Python environment"): + python, environment = get_python_env_info(path, python, False, force_generate) + + _warn_on_ignored_conda_env(environment) + + environment_file_exists = exists(join(base_dir, environment.filename)) + + if environment_file_exists and not force_generate: + click.secho( + " Warning: %s already exists and will not be overwritten." % environment.filename, + fg="yellow", + ) + else: + with cli_feedback("Creating %s" % environment.filename): + write_environment_file(environment, base_dir) + + with cli_feedback("Creating manifest.json"): + write_voila_manifest_json( + path, + entrypoint, + environment, + AppModes.JUPYTER_VOILA, + extra_files, + exclude, + force_generate, + image, + multi_notebook, + ) + + @write_manifest.command( name="quarto", short_help="Create a manifest.json file for Quarto content.", diff --git a/rsconnect/models.py b/rsconnect/models.py index b20638d4..cac1ebdc 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -78,6 +78,7 @@ class AppModes(object): SHINY_QUARTO = AppMode(13, "quarto-shiny", "Shiny Quarto Document") STATIC_QUARTO = AppMode(14, "quarto-static", "Quarto Document", ".qmd") PYTHON_SHINY = AppMode(15, "python-shiny", "Python Shiny Application") + JUPYTER_VOILA = AppMode(16, "jupyter-voila", "Jupyter Voila Application") _modes = [ UNKNOWN, @@ -96,6 +97,7 @@ class AppModes(object): SHINY_QUARTO, STATIC_QUARTO, PYTHON_SHINY, + JUPYTER_VOILA, ] _cloud_to_connect_modes = { diff --git a/tests/test_bundle.py b/tests/test_bundle.py index fce50af3..834b97e8 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -8,7 +8,7 @@ import tempfile import pytest from unittest import TestCase -from os.path import dirname, join, basename +from os.path import dirname, join, basename, abspath from rsconnect.bundle import ( _default_title, @@ -21,6 +21,7 @@ make_notebook_html_bundle, make_notebook_source_bundle, keep_manifest_specified_file, + make_voila_bundle, to_bytes, make_source_manifest, make_quarto_manifest, @@ -28,6 +29,9 @@ validate_entry_point, validate_extra_files, which_python, + guess_deploy_dir, + Manifest, + create_voila_manifest, ) import rsconnect.bundle from rsconnect.exception import RSConnectException @@ -801,3 +805,539 @@ def test_is_not_executable(self): with tempfile.NamedTemporaryFile() as tmpfile: with self.assertRaises(RSConnectException): which_python(tmpfile.name) + + +cur_dir = os.path.dirname(__file__) +bqplot_dir = os.path.join(cur_dir, "./testdata/voila/bqplot/") +bqplot_ipynb = os.path.join(bqplot_dir, "bqplot.ipynb") +dashboard_dir = os.path.join(cur_dir, "./testdata/voila/dashboard/") +dashboard_ipynb = os.path.join(dashboard_dir, "dashboard.ipynb") +multivoila_dir = os.path.join(cur_dir, "./testdata/voila/multi-voila/") + + +class Test_guess_deploy_dir(TestCase): + def test_guess_deploy_dir(self): + with self.assertRaises(RSConnectException): + guess_deploy_dir(None, None) + with self.assertRaises(RSConnectException): + guess_deploy_dir(None, bqplot_dir) + with self.assertRaises(RSConnectException): + guess_deploy_dir(bqplot_dir, bqplot_dir) + self.assertEqual(abspath(bqplot_dir), guess_deploy_dir(bqplot_dir, None)) + self.assertEqual(abspath(bqplot_dir), guess_deploy_dir(bqplot_ipynb, None)) + self.assertEqual(abspath(bqplot_dir), guess_deploy_dir(bqplot_ipynb, bqplot_ipynb)) + self.assertEqual(abspath(bqplot_dir), guess_deploy_dir(bqplot_dir, "bqplot.ipynb")) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + None, + None, + ), + ( + None, + bqplot_ipynb, + ), + ( + bqplot_dir, + bqplot_dir, + ), + ( + bqplot_dir, + None, + ), + ( + bqplot_ipynb, + None, + ), + ( + bqplot_ipynb, + bqplot_ipynb, + ), + ( + bqplot_dir, + bqplot_ipynb, + ), + ], +) +def test_create_voila_manifest_1(path, entrypoint): + environment = Environment( + conda=None, + contents="bqplot\n", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": "bqplot.ipynb"}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "9cce1aac313043abd5690f67f84338ed"}, + "bqplot.ipynb": {"checksum": "79f8622228eded646a3038848de5ffd9"}, + }, + } + manifest = Manifest() + if (path, entrypoint) in ( + (None, None), + (None, bqplot_ipynb), + (bqplot_dir, bqplot_dir), + ): + with pytest.raises(RSConnectException) as _: + manifest = create_voila_manifest( + path, + entrypoint, + environment, + app_mode=AppModes.JUPYTER_VOILA, + extra_files=None, + excludes=None, + force_generate=True, + image=None, + multi_notebook=False, + ) + else: + manifest = create_voila_manifest( + path, + entrypoint, + environment, + app_mode=AppModes.JUPYTER_VOILA, + extra_files=None, + excludes=None, + force_generate=True, + image=None, + multi_notebook=False, + ) + assert ans == json.loads(manifest.flattened_copy.json) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + dashboard_dir, + dashboard_ipynb, + ), + ], +) +def test_create_voila_manifest_2(path, entrypoint): + environment = Environment( + conda=None, + contents="numpy\nipywidgets\nbqplot\n", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": "dashboard.ipynb"}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "d51994456975ff487749acc247ae6d63"}, + "bqplot.ipynb": {"checksum": "79f8622228eded646a3038848de5ffd9"}, + "dashboard.ipynb": {"checksum": "6b42a0730d61e5344a3e734f5bbeec25"}, + }, + } + manifest = create_voila_manifest( + path, + entrypoint, + environment, + app_mode=AppModes.JUPYTER_VOILA, + extra_files=None, + excludes=None, + force_generate=True, + image=None, + multi_notebook=False, + ) + assert ans == json.loads(manifest.flattened_copy.json) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + None, + None, + ), + ( + None, + bqplot_ipynb, + ), + ( + multivoila_dir, + multivoila_dir, + ), + ( + multivoila_dir, + None, + ), + ( + bqplot_ipynb, + None, + ), + ( + bqplot_ipynb, + bqplot_ipynb, + ), + ( + multivoila_dir, + bqplot_ipynb, + ), + ], +) +def test_create_voila_manifest_multi_notebook(path, entrypoint): + environment = Environment( + conda=None, + contents="bqplot\n", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": "multi-voila"}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "9cce1aac313043abd5690f67f84338ed"}, + "bqplot/bqplot.ipynb": {"checksum": "9f283b29889500e6c78e83ad1257e03f"}, + "dashboard/dashboard.ipynb": {"checksum": "6b42a0730d61e5344a3e734f5bbeec25"}, + }, + } + manifest = Manifest() + if (path, entrypoint) in ( + (None, None), + (None, bqplot_ipynb), + (multivoila_dir, multivoila_dir), + (bqplot_ipynb, None), + (bqplot_ipynb, bqplot_ipynb), + ): + with pytest.raises(RSConnectException) as _: + manifest = create_voila_manifest( + path, + entrypoint, + environment, + app_mode=AppModes.JUPYTER_VOILA, + extra_files=None, + excludes=None, + force_generate=True, + image=None, + multi_notebook=True, + ) + else: + manifest = create_voila_manifest( + path, + entrypoint, + environment, + app_mode=AppModes.JUPYTER_VOILA, + extra_files=None, + excludes=None, + force_generate=True, + image=None, + multi_notebook=True, + ) + assert ans == json.loads(manifest.flattened_copy.json) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + None, + None, + ), + ( + None, + bqplot_ipynb, + ), + ( + bqplot_dir, + bqplot_dir, + ), + ( + bqplot_dir, + None, + ), + ( + bqplot_ipynb, + None, + ), + ( + bqplot_ipynb, + bqplot_ipynb, + ), + ( + bqplot_dir, + bqplot_ipynb, + ), + ], +) +def test_make_voila_bundle( + path, + entrypoint, +): + environment = Environment( + conda=None, + contents="bqplot", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": "bqplot.ipynb"}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "9395f3162b7779c57c86b187fa441d96"}, + "bqplot.ipynb": {"checksum": "79f8622228eded646a3038848de5ffd9"}, + }, + } + if (path, entrypoint) in ( + (None, None), + (None, bqplot_ipynb), + (bqplot_dir, bqplot_dir), + ): + with pytest.raises(RSConnectException) as _: + bundle = make_voila_bundle( + path, + entrypoint, + extra_files=None, + excludes=None, + force_generate=True, + environment=environment, + image=None, + multi_notebook=False, + ) + else: + with make_voila_bundle( + path, + entrypoint, + extra_files=None, + excludes=None, + force_generate=True, + environment=environment, + image=None, + multi_notebook=False, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + names = sorted(tar.getnames()) + assert names == [ + "bqplot.ipynb", + "manifest.json", + "requirements.txt", + ] + reqs = tar.extractfile("requirements.txt").read() + assert reqs == b"bqplot" + assert ans == json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + None, + None, + ), + ( + None, + bqplot_ipynb, + ), + ( + multivoila_dir, + multivoila_dir, + ), + ( + multivoila_dir, + None, + ), + ( + bqplot_ipynb, + None, + ), + ( + bqplot_ipynb, + bqplot_ipynb, + ), + ( + multivoila_dir, + bqplot_ipynb, + ), + ], +) +def test_make_voila_bundle_multi_notebook( + path, + entrypoint, +): + environment = Environment( + conda=None, + contents="bqplot", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": ""}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "9395f3162b7779c57c86b187fa441d96"}, + "bqplot/bqplot.ipynb": {"checksum": "9f283b29889500e6c78e83ad1257e03f"}, + "dashboard/dashboard.ipynb": {"checksum": "6b42a0730d61e5344a3e734f5bbeec25"}, + }, + } + if (path, entrypoint) in ( + (None, None), + (None, bqplot_ipynb), + (multivoila_dir, multivoila_dir), + (bqplot_ipynb, None), + (bqplot_ipynb, bqplot_ipynb), + ): + with pytest.raises(RSConnectException) as _: + bundle = make_voila_bundle( + path, + entrypoint, + extra_files=None, + excludes=None, + force_generate=True, + environment=environment, + image=None, + multi_notebook=True, + ) + else: + with make_voila_bundle( + path, + entrypoint, + extra_files=None, + excludes=None, + force_generate=True, + environment=environment, + image=None, + multi_notebook=True, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + names = sorted(tar.getnames()) + assert names == [ + "bqplot/bqplot.ipynb", + "dashboard/dashboard.ipynb", + "manifest.json", + "requirements.txt", + ] + reqs = tar.extractfile("requirements.txt").read() + assert reqs == b"bqplot" + assert ans == json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + + +@pytest.mark.parametrize( + ( + "path", + "entrypoint", + ), + [ + ( + dashboard_dir, + dashboard_ipynb, + ), + ], +) +def test_make_voila_bundle_2( + path, + entrypoint, +): + environment = Environment( + conda=None, + contents="numpy\nipywidgets\nbqplot\n", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="23.0", + python="3.8.12", + source="file", + ) + ans = { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "jupyter-voila", "entrypoint": "dashboard.ipynb"}, + "python": { + "version": "3.8.12", + "package_manager": {"name": "pip", "version": "23.0", "package_file": "requirements.txt"}, + }, + "files": { + "requirements.txt": {"checksum": "d51994456975ff487749acc247ae6d63"}, + "bqplot.ipynb": {"checksum": "79f8622228eded646a3038848de5ffd9"}, + "dashboard.ipynb": {"checksum": "6b42a0730d61e5344a3e734f5bbeec25"}, + }, + } + with make_voila_bundle( + path, + entrypoint, + extra_files=None, + excludes=None, + force_generate=True, + environment=environment, + image=None, + multi_notebook=False, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + names = sorted(tar.getnames()) + assert names == [ + "bqplot.ipynb", + "dashboard.ipynb", + "manifest.json", + "requirements.txt", + ] + reqs = tar.extractfile("requirements.txt").read() + assert reqs == b"numpy\nipywidgets\nbqplot\n" + assert ans == json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) diff --git a/tests/testdata/voila/bqplot/bqplot.ipynb b/tests/testdata/voila/bqplot/bqplot.ipynb new file mode 100644 index 00000000..66ebe130 --- /dev/null +++ b/tests/testdata/voila/bqplot/bqplot.ipynb @@ -0,0 +1,62 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# So easy, *voilà*!\n", + "\n", + "In this example notebook, we demonstrate how Voilà can render custom Jupyter widgets such as [bqplot](https://github.com/bloomberg/bqplot). " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "\n", + "plt.figure(1, title='Line Chart')\n", + "np.random.seed(0)\n", + "n = 200\n", + "x = np.linspace(0.0, 10.0, n)\n", + "y = np.cumsum(np.random.randn(n))\n", + "plt.plot(x, y)\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/testdata/voila/bqplot/requirements.txt b/tests/testdata/voila/bqplot/requirements.txt new file mode 100644 index 00000000..35c00ee4 --- /dev/null +++ b/tests/testdata/voila/bqplot/requirements.txt @@ -0,0 +1 @@ +bqplot diff --git a/tests/testdata/voila/dashboard/bqplot.ipynb b/tests/testdata/voila/dashboard/bqplot.ipynb new file mode 100644 index 00000000..66ebe130 --- /dev/null +++ b/tests/testdata/voila/dashboard/bqplot.ipynb @@ -0,0 +1,62 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# So easy, *voilà*!\n", + "\n", + "In this example notebook, we demonstrate how Voilà can render custom Jupyter widgets such as [bqplot](https://github.com/bloomberg/bqplot). " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "\n", + "plt.figure(1, title='Line Chart')\n", + "np.random.seed(0)\n", + "n = 200\n", + "x = np.linspace(0.0, 10.0, n)\n", + "y = np.cumsum(np.random.randn(n))\n", + "plt.plot(x, y)\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/testdata/voila/dashboard/dashboard.ipynb b/tests/testdata/voila/dashboard/dashboard.ipynb new file mode 100644 index 00000000..cf0c2bce --- /dev/null +++ b/tests/testdata/voila/dashboard/dashboard.ipynb @@ -0,0 +1,148 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This demo uses Voilà to render a notebook to a custom HTML page using gridstack.js for the layout of each output. In the cell metadata you can change the default cell with and height (in grid units between 1 and 12) by specifying.\n", + " * `grid_row`\n", + " * `grid_columns`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "n = 200\n", + "\n", + "x = np.linspace(0.0, 10.0, n)\n", + "y = np.cumsum(np.random.randn(n)*10).astype(int)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "label_selected = widgets.Label(value=\"Selected: 0\")\n", + "label_selected" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "grid_columns": 8, + "grid_rows": 4 + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "import bqplot\n", + "\n", + "fig = plt.figure( title='Histogram')\n", + "np.random.seed(0)\n", + "hist = plt.hist(y, bins=25)\n", + "hist.scales['sample'].min = float(y.min())\n", + "hist.scales['sample'].max = float(y.max())\n", + "display(fig)\n", + "fig.layout.width = 'auto'\n", + "fig.layout.height = 'auto'\n", + "fig.layout.min_height = '300px' # so it shows nicely in the notebook\n", + "fig.layout.flex = '1'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "grid_columns": 12, + "grid_rows": 6 + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "import bqplot\n", + "\n", + "fig = plt.figure( title='Line Chart')\n", + "np.random.seed(0)\n", + "n = 200\n", + "p = plt.plot(x, y)\n", + "fig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig.layout.width = 'auto'\n", + "fig.layout.height = 'auto'\n", + "fig.layout.min_height = '300px' # so it shows nicely in the notebook\n", + "fig.layout.flex = '1'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "brushintsel = bqplot.interacts.BrushIntervalSelector(scale=p.scales['x'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def update_range(*args):\n", + " label_selected.value = \"Selected range {}\".format(brushintsel.selected)\n", + " mask = (x > brushintsel.selected[0]) & (x < brushintsel.selected[1])\n", + " hist.sample = y[mask]\n", + " \n", + "brushintsel.observe(update_range, 'selected')\n", + "fig.interaction = brushintsel" + ] + } + ], + "metadata": { + "celltoolbar": "Edit Metadata", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/testdata/voila/dashboard/requirements.txt b/tests/testdata/voila/dashboard/requirements.txt new file mode 100644 index 00000000..9d6d6ab4 --- /dev/null +++ b/tests/testdata/voila/dashboard/requirements.txt @@ -0,0 +1,4 @@ +numpy +ipywidgets +bqplot + diff --git a/tests/testdata/voila/multi-voila/bqplot/bqplot.ipynb b/tests/testdata/voila/multi-voila/bqplot/bqplot.ipynb new file mode 100644 index 00000000..92b7d19d --- /dev/null +++ b/tests/testdata/voila/multi-voila/bqplot/bqplot.ipynb @@ -0,0 +1,62 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# So easy, *voilà*!\n", + "\n", + "In this example notebook, we demonstrate how Voilà can render custom Jupyter widgets such as [bqplot](https://github.com/bloomberg/bqplot). " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "\n", + "plt.figure(1, title='Line Chart')\n", + "np.random.seed(0)\n", + "n = 200\n", + "x = np.linspace(0.0, 10.0, n)\n", + "y = np.cumsum(np.random.randn(n))\n", + "plt.plot(x, y)\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/testdata/voila/multi-voila/dashboard/dashboard.ipynb b/tests/testdata/voila/multi-voila/dashboard/dashboard.ipynb new file mode 100644 index 00000000..cf0c2bce --- /dev/null +++ b/tests/testdata/voila/multi-voila/dashboard/dashboard.ipynb @@ -0,0 +1,148 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This demo uses Voilà to render a notebook to a custom HTML page using gridstack.js for the layout of each output. In the cell metadata you can change the default cell with and height (in grid units between 1 and 12) by specifying.\n", + " * `grid_row`\n", + " * `grid_columns`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "n = 200\n", + "\n", + "x = np.linspace(0.0, 10.0, n)\n", + "y = np.cumsum(np.random.randn(n)*10).astype(int)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "label_selected = widgets.Label(value=\"Selected: 0\")\n", + "label_selected" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "grid_columns": 8, + "grid_rows": 4 + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "import bqplot\n", + "\n", + "fig = plt.figure( title='Histogram')\n", + "np.random.seed(0)\n", + "hist = plt.hist(y, bins=25)\n", + "hist.scales['sample'].min = float(y.min())\n", + "hist.scales['sample'].max = float(y.max())\n", + "display(fig)\n", + "fig.layout.width = 'auto'\n", + "fig.layout.height = 'auto'\n", + "fig.layout.min_height = '300px' # so it shows nicely in the notebook\n", + "fig.layout.flex = '1'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "grid_columns": 12, + "grid_rows": 6 + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "from bqplot import pyplot as plt\n", + "import bqplot\n", + "\n", + "fig = plt.figure( title='Line Chart')\n", + "np.random.seed(0)\n", + "n = 200\n", + "p = plt.plot(x, y)\n", + "fig" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig.layout.width = 'auto'\n", + "fig.layout.height = 'auto'\n", + "fig.layout.min_height = '300px' # so it shows nicely in the notebook\n", + "fig.layout.flex = '1'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "brushintsel = bqplot.interacts.BrushIntervalSelector(scale=p.scales['x'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def update_range(*args):\n", + " label_selected.value = \"Selected range {}\".format(brushintsel.selected)\n", + " mask = (x > brushintsel.selected[0]) & (x < brushintsel.selected[1])\n", + " hist.sample = y[mask]\n", + " \n", + "brushintsel.observe(update_range, 'selected')\n", + "fig.interaction = brushintsel" + ] + } + ], + "metadata": { + "celltoolbar": "Edit Metadata", + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/testdata/voila/multi-voila/requirements.txt b/tests/testdata/voila/multi-voila/requirements.txt new file mode 100644 index 00000000..9d6d6ab4 --- /dev/null +++ b/tests/testdata/voila/multi-voila/requirements.txt @@ -0,0 +1,4 @@ +numpy +ipywidgets +bqplot +