diff --git a/CHANGELOG.md b/CHANGELOG.md index 097b542e..e3173371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## [1.5.4] - TBD ### Added @@ -15,6 +16,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rsconnect-python does not inspect the file contents to identify the object name, which must be one of the default names that Connect expects (`app`, `application`, `create_app`, or `make_app`). +- Ability to hide code cells when rendering Jupyter notebooks. + +After setting up Connect and rsconnect-python, the user can render a Jupyter notebook without its corresponding code cells by passing the ' hide-all-input' flag through the rsconnect cli: + +``` +rsconnect deploy notebook \ + -n server \ + -k APIKey \ + --hide-all-input \ + hello_world.ipynb +``` + +To selectively hide the input of cells, the user can add a tag call 'hide_input' to the cell, then pass the ' hide-tagged-input' flag through the rsconnect cli: + +``` +rsconnect deploy notebook \ + -n server \ + -k APIKey \ + --hide-tagged-input \ + hello_world.ipynb +``` + ## [1.5.3] - 2021-05-06 ### Added diff --git a/README.md b/README.md index 551a6b9a..f6cff14a 100644 --- a/README.md +++ b/README.md @@ -513,3 +513,35 @@ directory specified above.
+ +### Hide Jupyter Notebook Input Code Cells + +The user can render a Jupyter notebook without its corresponding input code cells by passing the '--hide-all-input' flag through the cli: + +``` +rsconnect deploy notebook \ + --server https://connect.example.org:3939 \ + --api-key my-api-key \ + --hide-all-input \ + my-notebook.ipynb +``` + +To selectively hide input cells in a Jupyter notebook, the user needs to follow a two step process: +1. tag cells with the 'hide_input' tag, +2. then pass the ' --hide-tagged-input' flag through the cli: + +``` +rsconnect deploy notebook \ + --server https://connect.example.org:3939 \ + --api-key my-api-key \ + --hide-tagged-input \ + my-notebook.ipynb +``` + +By default, rsconnect-python does not install Jupyter notebook related depenencies. These dependencies are installed via rsconnect-jupyter. When the user is using the hide input features in rsconnect-python by itself without rsconnect-jupyter, he/she needs to install the following package depenecies: + +``` +notebook +nbformat +nbconvert>=5.6.1 +``` diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 7987348c..16686f6d 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -473,6 +473,8 @@ def deploy_jupyter_notebook( conda_mode=False, force_generate=False, log_callback=None, + hide_all_input=False, + hide_tagged_input=False, ): """ A function to deploy a Jupyter notebook to Connect. Depending on the files involved @@ -496,6 +498,8 @@ def deploy_jupyter_notebook( (the default) the lines from the deployment log will be returned as a sequence. If a log callback is provided, then None will be returned for the log lines part of the return tuple. + :param hide_all_input: if True, will hide all input cells when rendering output + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output :return: the ultimate URL where the deployed app may be accessed and the sequence of log lines. The log lines value will be None if a log callback was provided. """ @@ -504,7 +508,9 @@ def deploy_jupyter_notebook( connect_server, app_store, file_name, new, app_id, title, static ) python, environment = get_python_env_info(file_name, python, conda_mode=conda_mode, force_generate=force_generate,) - bundle = create_notebook_deployment_bundle(file_name, extra_files, app_mode, python, environment) + bundle = create_notebook_deployment_bundle( + file_name, extra_files, app_mode, python, environment, hide_all_input, hide_tagged_input + ) return _finalize_deploy( connect_server, app_store, @@ -1097,6 +1103,8 @@ def get_python_env_info(file_name, python, conda_mode=False, force_generate=Fals def create_notebook_deployment_bundle( file_name, extra_files, app_mode, python, environment, extra_files_need_validating=True, + hide_all_input=None, + hide_tagged_input=None, ): """ Create an in-memory bundle, ready to deploy. @@ -1107,6 +1115,8 @@ def create_notebook_deployment_bundle( :param python: information about the version of Python being used. :param environment: environmental information. :param extra_files_need_validating: a flag indicating whether the list of extra + :param hide_all_input: if True, will hide all input cells when rendering output + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output files should be validated or not. Part of validating includes qualifying each with the parent directory of the notebook file. If you provide False here, make sure the names are properly qualified first. @@ -1119,13 +1129,13 @@ def create_notebook_deployment_bundle( if app_mode == AppModes.STATIC: try: - return make_notebook_html_bundle(file_name, python) + return make_notebook_html_bundle(file_name, python, hide_all_input, hide_tagged_input) except subprocess.CalledProcessError as exc: # Jupyter rendering failures are often due to # user code failing, vs. an internal failure of rsconnect-python. raise api.RSConnectException(str(exc)) else: - return make_notebook_source_bundle(file_name, environment, extra_files) + return make_notebook_source_bundle(file_name, environment, extra_files, hide_all_input, hide_tagged_input) def create_api_deployment_bundle( @@ -1190,7 +1200,13 @@ def spool_deployment_log(connect_server, app, log_callback): def create_notebook_manifest_and_environment_file( - entry_point_file, environment, app_mode=None, extra_files=None, force=True + entry_point_file, + environment, + app_mode=None, + extra_files=None, + force=True, + hide_all_input=False, + hide_tagged_input=False, ): """ Creates and writes a manifest.json file for the given notebook entry point file. @@ -1206,13 +1222,18 @@ def create_notebook_manifest_and_environment_file( :param extra_files: any extra files that should be included in the manifest. :param force: if True, forces the environment file to be written. even if it already exists. + :param hide_all_input: if True, will hide all input cells when rendering output + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output :return: """ - if not write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files) or force: + if ( + not write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input) + or force + ): write_environment_file(environment, dirname(entry_point_file)) -def write_notebook_manifest_json(entry_point_file, environment, app_mode=None, extra_files=None): +def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input): """ Creates and writes a manifest.json file for the given entry point file. If the application mode is not provided, an attempt will be made to resolve one @@ -1225,6 +1246,8 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode=None, e :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. :param extra_files: any extra files that should be included in the manifest. + :param hide_all_input: if True, will hide all input cells when rendering output + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 942bde81..37ef2962 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -137,8 +137,8 @@ def bundle_add_buffer(bundle, filename, contents): bundle.addfile(file_info, buf) -def write_manifest(relative_dir, nb_name, environment, output_dir): - # type: (str, str, Environment, str) -> typing.Tuple[list, list] +def write_manifest(relative_dir, nb_name, environment, output_dir, hide_all_input=False, hide_tagged_input=False): + # type: (...) -> typing.Tuple[list, list] """Create a manifest for source publishing the specified notebook. The manifest will be written to `manifest.json` in the output directory.. @@ -148,6 +148,12 @@ def write_manifest(relative_dir, nb_name, environment, output_dir): """ manifest_filename = "manifest.json" manifest = make_source_manifest(nb_name, environment, AppModes.JUPYTER_NOTEBOOK) + if hide_all_input: + if 'jupyter' not in manifest: manifest['jupyter']= {} + manifest['jupyter'].update({'hide_all_input': hide_all_input}) + if hide_tagged_input: + if 'jupyter' not in manifest: manifest['jupyter']= {} + manifest['jupyter'].update({'hide_tagged_input': hide_tagged_input}) manifest_file = join(output_dir, manifest_filename) created = [] skipped = [] @@ -205,6 +211,8 @@ def make_notebook_source_bundle( file, # type: str environment, # type: Environment extra_files=None, # type: typing.Optional[typing.List[str]] + hide_all_input=False, + hide_tagged_input=False, ): # type: (...) -> typing.IO[bytes] """Create a bundle containing the specified notebook and python environment. @@ -217,6 +225,12 @@ def make_notebook_source_bundle( nb_name = basename(file) manifest = make_source_manifest(nb_name, environment, AppModes.JUPYTER_NOTEBOOK) + if hide_all_input: + if 'jupyter' not in manifest: manifest['jupyter']= {} + manifest['jupyter'].update({'hide_all_input': hide_all_input}) + if hide_tagged_input: + if 'jupyter' not in manifest: manifest['jupyter']= {} + manifest['jupyter'].update({'hide_tagged_input': hide_tagged_input}) manifest_add_file(manifest, nb_name, base_dir) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -259,6 +273,8 @@ def make_html_manifest(filename): def make_notebook_html_bundle( filename, # type: str python, # type: str + hide_all_input=False, + hide_tagged_input=False, check_output=subprocess.check_output, # type: typing.Callable ): # type: (...) -> typing.IO[bytes] @@ -274,6 +290,14 @@ def make_notebook_html_bundle( "--to=html", filename, ] + if hide_all_input and hide_tagged_input or hide_all_input: + cmd.append('--no-input') + elif hide_tagged_input: + version = check_output([python, '--version']).decode("utf-8") + if version >= 'Python 3': + cmd.append('--TagRemovePreprocessor.remove_input_tags=hide_input') + else: + cmd.append("--TagRemovePreprocessor.remove_input_tags=['hide_input']") try: output = check_output(cmd) except subprocess.CalledProcessError: diff --git a/rsconnect/main.py b/rsconnect/main.py index 23d023f9..6f296dbc 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -522,6 +522,8 @@ def _deploy_bundle( "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") +@click.option("--hide-all-input", is_flag=True, default=False, help="Hide all input cells when rendering output") +@click.option("--hide-tagged-input", is_flag=True, default=False, help="Hide input code cells with the 'hide_input' tag") @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( "extra_files", nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), @@ -542,6 +544,8 @@ def deploy_notebook( verbose, file, extra_files, + hide_all_input, + hide_tagged_input, ): set_verbosity(verbose) @@ -570,8 +574,9 @@ def deploy_notebook( _warn_on_ignored_requirements(dirname(file), environment.filename) with cli_feedback("Creating deployment bundle"): - bundle = create_notebook_deployment_bundle(file, extra_files, app_mode, python, environment, False) - + bundle = create_notebook_deployment_bundle( + file, extra_files, app_mode, python, environment, False, hide_all_input, hide_tagged_input + ) _deploy_bundle( connect_server, app_store, file, app_id, app_mode, deployment_name, title, default_title, bundle, ) @@ -936,12 +941,16 @@ def write_manifest(): @click.option( "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option("--hide-all-input", help="Hide all input cells when rendering output") +@click.option("--hide-tagged-input", is_flag=True, default=None, help="Hide input code cells with the 'hide_input' tag") @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( "extra_files", nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) -def write_manifest_notebook(overwrite, python, conda, force_generate, verbose, file, extra_files): +def write_manifest_notebook( + overwrite, python, conda, force_generate, verbose, file, extra_files, hide_all_input=None, hide_tagged_input=None +): set_verbosity(verbose) with cli_feedback("Checking arguments"): validate_file_is_notebook(file) @@ -960,7 +969,12 @@ def write_manifest_notebook(overwrite, python, conda, force_generate, verbose, f with cli_feedback("Creating manifest.json"): environment_file_exists = write_notebook_manifest_json( - file, environment, AppModes.JUPYTER_NOTEBOOK, extra_files + file, + environment, + AppModes.JUPYTER_NOTEBOOK, + extra_files, + hide_all_input, + hide_tagged_input, ) if environment_file_exists and not force_generate: