diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8dba11..a0cb8990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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). +## Unreleased + +### Added + +- Add `--disable-env-management`, `--disable-env-management-py` and `--disable-env-management-r` flags for all content types + that support environment restores. These flags indicate to Connect that the user is responsible for Python/R package + installation, and Connect should not install packages during the build. The Python/R packages must still be available in the runtime + environment in order to run the content. This is especially useful if off-host execution is enabled when the execution environment + (specified by `--image`) already contains the required packages. Requires Posit Connect `>=2023.07.0`. + ## [1.19.1] - 2023-08-01 ### Added diff --git a/rsconnect/actions.py b/rsconnect/actions.py index ac6a941e..4af40e60 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -527,6 +527,8 @@ def write_quarto_manifest_json( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given Quarto project. @@ -538,6 +540,10 @@ def write_quarto_manifest_json( :param extra_files: Any extra files to include in the manifest. :param excludes: A sequence of glob patterns to exclude when enumerating files to bundle. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) @@ -549,6 +555,8 @@ def write_quarto_manifest_json( extra_files, excludes, image, + env_management_py, + env_management_r, ) base_dir = file_or_directory @@ -624,6 +632,8 @@ def deploy_jupyter_notebook( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ A function to deploy a Jupyter notebook to Connect. Depending on the files involved @@ -651,6 +661,10 @@ def deploy_jupyter_notebook( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -699,6 +713,8 @@ def deploy_jupyter_notebook( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) else: ce.make_bundle( @@ -709,6 +725,8 @@ def deploy_jupyter_notebook( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) ce.deploy_bundle().save_deployed_info().emit_task_log() @@ -747,6 +765,8 @@ def deploy_app( extra_files: typing.List[str] = None, env_vars: typing.Dict[str, str] = None, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, account: str = None, token: str = None, secret: str = None, @@ -798,6 +818,8 @@ def deploy_app( extra_files, excludes, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) .deploy_bundle() .save_deployed_info() @@ -819,6 +841,8 @@ def deploy_python_api( force_generate: bool, log_callback: typing.Callable, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python WSGi API module to Connect. Depending on the files involved @@ -843,6 +867,10 @@ def deploy_python_api( If a log callback is provided, then None will be returned for the log lines part of the return tuple. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -863,6 +891,8 @@ def deploy_python_fastapi( force_generate: bool, log_callback: typing.Callable, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python ASGI API module to Posit Connect. Depending on the files involved @@ -887,6 +917,10 @@ def deploy_python_fastapi( If a log callback is provided, then None will be returned for the log lines part of the return tuple. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -949,6 +983,8 @@ def deploy_dash_app( force_generate: bool, log_callback: typing.Callable, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Dash app module to Connect. Depending on the files involved @@ -973,6 +1009,10 @@ def deploy_dash_app( If a log callback is provided, then None will be returned for the log lines part of the return tuple. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -993,6 +1033,8 @@ def deploy_streamlit_app( force_generate: bool, log_callback: typing.Callable, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Streamlit app module to Connect. Depending on the files involved @@ -1017,6 +1059,10 @@ def deploy_streamlit_app( If a log callback is provided, then None will be returned for the log lines part of the return tuple. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -1037,6 +1083,8 @@ def deploy_bokeh_app( force_generate: bool, log_callback: typing.Callable, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Bokeh app module to Connect. Depending on the files involved @@ -1061,6 +1109,10 @@ def deploy_bokeh_app( If a log callback is provided, then None will be returned for the log lines part of the return tuple. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :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. """ @@ -1144,6 +1196,8 @@ def create_notebook_deployment_bundle( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1161,6 +1215,10 @@ def create_notebook_deployment_bundle( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the bundle. """ @@ -1177,6 +1235,8 @@ def create_notebook_deployment_bundle( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) except subprocess.CalledProcessError as exc: # Jupyter rendering failures are often due to @@ -1190,6 +1250,8 @@ def create_notebook_deployment_bundle( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) @@ -1202,6 +1264,8 @@ def create_api_deployment_bundle( environment: Environment, extra_files_need_validating: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1217,6 +1281,10 @@ def create_api_deployment_bundle( with the specified directory. If you provide False here, make sure the names are properly qualified first. Previous default = True. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the bundle. """ entry_point = validate_entry_point(entry_point, directory) @@ -1227,7 +1295,8 @@ def create_api_deployment_bundle( if app_mode is None: app_mode = AppModes.PYTHON_API - return make_api_bundle(directory, entry_point, app_mode, environment, extra_files, excludes, image) + return make_api_bundle(directory, entry_point, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r) def create_quarto_deployment_bundle( @@ -1238,6 +1307,8 @@ def create_quarto_deployment_bundle( inspect: typing.Dict[str, typing.Any], environment: Environment, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1253,12 +1324,17 @@ def create_quarto_deployment_bundle( with the specified directory. If you provide False here, make sure the names are properly qualified first. Previous default = True. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the bundle. """ if app_mode is None: app_mode = AppModes.STATIC_QUARTO - return make_quarto_source_bundle(file_or_directory, inspect, app_mode, environment, extra_files, excludes, image) + return make_quarto_source_bundle(file_or_directory, inspect, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r) def deploy_bundle( @@ -1336,6 +1412,8 @@ def create_notebook_manifest_and_environment_file( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given notebook entry point file. @@ -1355,12 +1433,17 @@ def create_notebook_manifest_and_environment_file( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: an optional docker image for off-host execution. Previous default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if ( not write_notebook_manifest_json( - entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, image + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, + image, env_management_py, env_management_r, ) or force ): @@ -1375,6 +1458,8 @@ def write_notebook_manifest_json( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If @@ -1392,6 +1477,10 @@ def write_notebook_manifest_json( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ @@ -1407,7 +1496,8 @@ def write_notebook_manifest_json( if app_mode == AppModes.UNKNOWN: raise RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) - manifest_data = make_source_manifest(app_mode, environment, file_name, None, image) + manifest_data = make_source_manifest(app_mode, environment, file_name, None, + image, env_management_py, env_management_r) manifest_add_file(manifest_data, file_name, directory) manifest_add_buffer(manifest_data, environment.filename, environment.contents) @@ -1428,6 +1518,8 @@ def create_api_manifest_and_environment_file( excludes: typing.List[str], force: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given Python API entry point. If @@ -1444,11 +1536,16 @@ def create_api_manifest_and_environment_file( :param force: if True, forces the environment file to be written. even if it already exists. Previous default = True. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) if ( - not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, image) + not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, + image, env_management_py, env_management_r) or force ): write_environment_file(environment, directory) @@ -1462,6 +1559,8 @@ def write_api_manifest_json( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If @@ -1476,12 +1575,17 @@ def write_api_manifest_json( :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. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) extra_files = validate_extra_files(directory, extra_files) - manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, image) + manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r) manifest_path = join(directory, "manifest.json") write_manifest_json(manifest_path, manifest) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 8081ec01..d48693c6 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -66,6 +66,8 @@ def __init__( entrypoint: str = None, quarto_inspection: dict = None, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, primary_html: str = None, metadata: dict = None, files: dict = None, @@ -118,10 +120,17 @@ def __init__( }, } - if image: - self.data["environment"] = { - "image": image, - } + + if image or env_management_py is not None or env_management_r is not None: + self.data["environment"] = {} + if image: + self.data["environment"]["image"] = image + if env_management_py is not None or env_management_r is not None: + self.data["environment"]["environment_management"] = {} + if env_management_py is not None: + self.data["environment"]["environment_management"]["python"] = env_management_py + if env_management_r is not None: + self.data["environment"]["environment_management"]["r"] = env_management_r self.data["files"] = {} if files: @@ -298,6 +307,8 @@ def make_source_manifest( entrypoint: str, quarto_inspection: typing.Dict[str, typing.Any], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Dict[str, typing.Any]: manifest = { "version": 1, @@ -339,10 +350,16 @@ def make_source_manifest( }, } - if image: - manifest["environment"] = { - "image": image, - } + if image or env_management_py is not None or env_management_r is not None: + manifest["environment"] = {} + if image: + manifest["environment"]["image"] = image + if env_management_py is not None or env_management_r is not None: + manifest["environment"]["environment_management"] = {} + if env_management_py is not None: + manifest["environment"]["environment_management"]["python"] = env_management_py + if env_management_r is not None: + manifest["environment"]["environment_management"]["r"] = env_management_r manifest["files"] = {} @@ -435,6 +452,8 @@ def write_manifest( hide_all_input: bool = False, hide_tagged_input: bool = False, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[list, list]: """Create a manifest for source publishing the specified notebook. @@ -444,7 +463,8 @@ def write_manifest( Returns the list of filenames written. """ manifest_filename = "manifest.json" - manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, image) + manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, + image, env_management_py, env_management_r) if hide_all_input: if "jupyter" not in manifest: manifest["jupyter"] = {} @@ -513,6 +533,8 @@ def make_notebook_source_bundle( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """Create a bundle containing the specified notebook and python environment. @@ -523,7 +545,8 @@ def make_notebook_source_bundle( base_dir = dirname(file) nb_name = basename(file) - manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, image) + manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, + image, env_management_py, env_management_r) if hide_all_input: if "jupyter" not in manifest: manifest["jupyter"] = {} @@ -566,6 +589,8 @@ def make_quarto_source_bundle( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create a bundle containing the specified Quarto content and (optional) @@ -574,7 +599,8 @@ def make_quarto_source_bundle( Returns a file-like object containing the bundle tarball. """ manifest, relevant_files = make_quarto_manifest( - file_or_directory, inspect, app_mode, environment, extra_files, excludes, image + file_or_directory, inspect, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r, ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") @@ -599,6 +625,8 @@ def make_quarto_source_bundle( def make_html_manifest( filename: str, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Dict[str, typing.Any]: # noinspection SpellCheckingInspection manifest = { @@ -607,11 +635,18 @@ def make_html_manifest( "appmode": "static", "primary_html": filename, }, - } - if image: - manifest["environment"] = { - "image": image, - } + } # type: typing.Dict[str, typing.Any] + + if image or env_management_py is not None or env_management_r is not None: + manifest["environment"] = {} + if image: + manifest["environment"]["image"] = image + if env_management_py is not None or env_management_r is not None: + manifest["environment"]["environment_management"] = {} + if env_management_py is not None: + manifest["environment"]["environment_management"]["python"] = env_management_py + if env_management_r is not None: + manifest["environment"]["environment_management"]["r"] = env_management_r return manifest @@ -621,6 +656,8 @@ def make_notebook_html_bundle( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, check_output: typing.Callable = subprocess.check_output, ) -> typing.IO[bytes]: # noinspection SpellCheckingInspection @@ -656,7 +693,7 @@ def make_notebook_html_bundle( bundle_add_buffer(bundle, filename, output) # manifest - manifest = make_html_manifest(filename, image) + manifest = make_html_manifest(filename, image, env_management_py, env_management_r) bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest, indent=2)) # rewind file pointer @@ -802,6 +839,8 @@ def make_api_manifest( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: """ Makes a manifest for an API. @@ -813,6 +852,10 @@ def make_api_manifest( :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 image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the manifest and a list of the files involved. """ if is_environment_dir(directory): @@ -829,7 +872,8 @@ def make_api_manifest( excludes.extend(list_environment_dirs(directory)) relevant_files = create_file_list(directory, extra_files, excludes) - manifest = make_source_manifest(app_mode, environment, entry_point, None, image) + manifest = make_source_manifest(app_mode, environment, entry_point, None, + image, env_management_py, env_management_r) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -845,6 +889,8 @@ def create_html_manifest( extra_files: typing.List[str] = None, excludes: typing.List[str] = None, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, **kwargs ) -> Manifest: """ @@ -860,6 +906,10 @@ def create_html_manifest( :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. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the manifest data structure. """ if not path: @@ -887,7 +937,8 @@ def create_html_manifest( excludes.extend(["manifest.json"]) excludes.extend(list_environment_dirs(deploy_dir)) - manifest = Manifest(app_mode=AppModes.STATIC, entrypoint=entrypoint, primary_html=entrypoint, image=image) + manifest = Manifest(app_mode=AppModes.STATIC, entrypoint=entrypoint, primary_html=entrypoint, + image=image, env_management_py=env_management_py, env_management_r=env_management_r) manifest.deploy_dir = deploy_dir file_list = create_file_list(path, extra_files, excludes, use_abspath=True) @@ -903,6 +954,8 @@ def make_html_bundle( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create an html bundle, given a path and/or entrypoint. @@ -914,6 +967,10 @@ def make_html_bundle( :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 image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: a file-like object containing the bundle tarball. """ @@ -1065,6 +1122,8 @@ def make_voila_bundle( force_generate: bool, environment: Environment, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, multi_notebook: bool = False, ) -> typing.IO[bytes]: """ @@ -1080,6 +1139,10 @@ def make_voila_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. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: a file-like object containing the bundle tarball. """ @@ -1112,6 +1175,8 @@ def make_api_bundle( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.IO[bytes]: """ Create an API bundle, given a directory path and a manifest. @@ -1123,10 +1188,15 @@ def make_api_bundle( :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 image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: a file-like object containing the bundle tarball. """ manifest, relevant_files = make_api_manifest( - directory, entry_point, app_mode, environment, extra_files, excludes, image + directory, entry_point, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r, ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") @@ -1180,6 +1250,8 @@ def make_quarto_manifest( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: """ Makes a manifest for a Quarto project. @@ -1191,6 +1263,10 @@ def make_quarto_manifest( :param extra_files: Any extra files to include in the manifest. :param excludes: A sequence of glob patterns to exclude when enumerating files to bundle. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the manifest and a list of the files involved. """ if environment: @@ -1231,6 +1307,8 @@ def make_quarto_manifest( None, quarto_inspection, image, + env_management_py, + env_management_r, ) if environment: @@ -1520,6 +1598,8 @@ def create_notebook_manifest_and_environment_file( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given notebook entry point file. @@ -1539,11 +1619,16 @@ def create_notebook_manifest_and_environment_file( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: an optional docker image for off-host execution. Previous default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: """ if ( not write_notebook_manifest_json( - entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, image + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, + image, env_management_py, env_management_r, ) or force ): @@ -1558,6 +1643,8 @@ def write_notebook_manifest_json( hide_all_input: bool, hide_tagged_input: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If @@ -1575,6 +1662,10 @@ def write_notebook_manifest_json( :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output. Previous default = False. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ @@ -1589,7 +1680,8 @@ def write_notebook_manifest_json( if app_mode == AppModes.UNKNOWN: raise RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) - manifest_data = make_source_manifest(app_mode, environment, file_name, None, image) + manifest_data = make_source_manifest(app_mode, environment, file_name, None, + image, env_management_py, env_management_r) if hide_all_input or hide_tagged_input: if "jupyter" not in manifest_data: manifest_data["jupyter"] = dict() @@ -1627,6 +1719,8 @@ def create_voila_manifest( excludes: typing.List[str] = None, force_generate: bool = True, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, multi_notebook: bool = False, **kwargs ) -> Manifest: @@ -1643,6 +1737,10 @@ def create_voila_manifest( :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. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: the manifest data structure. """ if not path: @@ -1680,7 +1778,8 @@ def create_voila_manifest( 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 = Manifest(app_mode=AppModes.JUPYTER_VOILA, environment=environment, entrypoint=entrypoint, + image=image, env_management_py=env_management_py, env_management_r=env_management_r) manifest.deploy_dir = deploy_dir if entrypoint and isfile(entrypoint): validate_file_is_notebook(entrypoint) @@ -1703,6 +1802,8 @@ def write_voila_manifest_json( excludes: typing.List[str] = None, force_generate: bool = True, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, multi_notebook: bool = False, ) -> bool: """ @@ -1718,6 +1819,10 @@ def write_voila_manifest_json( :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. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether the manifest was written. """ manifest = create_voila_manifest(**locals()) @@ -1739,6 +1844,8 @@ def create_api_manifest_and_environment_file( excludes: typing.List[str], force: bool, image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given Python API entry point. If @@ -1755,10 +1862,15 @@ def create_api_manifest_and_environment_file( :param force: if True, forces the environment file to be written. even if it already exists. Previous default = True. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: """ if ( - not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, image) + not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes, + image, env_management_py, env_management_r) or force ): write_environment_file(environment, directory) @@ -1772,6 +1884,8 @@ def write_api_manifest_json( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> bool: """ Creates and writes a manifest.json file for the given entry point file. If @@ -1786,11 +1900,16 @@ def write_api_manifest_json( :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. Previous default = None. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ extra_files = validate_extra_files(directory, extra_files) - manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, image) + manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r) manifest_path = join(directory, "manifest.json") write_manifest_json(manifest_path, manifest) @@ -1847,6 +1966,8 @@ def write_quarto_manifest_json( extra_files: typing.List[str], excludes: typing.List[str], image: str = None, + env_management_py: bool = None, + env_management_r: bool = None, ) -> None: """ Creates and writes a manifest.json file for the given Quarto project. @@ -1858,10 +1979,15 @@ def write_quarto_manifest_json( :param extra_files: Any extra files to include in the manifest. :param excludes: A sequence of glob patterns to exclude when enumerating files to bundle. :param image: the optional docker image to be specified for off-host execution. Default = None. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. """ extra_files = validate_extra_files(directory, extra_files) - manifest, _ = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes, image) + manifest, _ = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes, + image, env_management_py, env_management_r) manifest_path = join(directory, "manifest.json") write_manifest_json(manifest_path, manifest) diff --git a/rsconnect/main.py b/rsconnect/main.py index 11d2545f..c9b6e23c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -243,6 +243,65 @@ def wrapper(*args, **kwargs): return wrapper +# This callback handles the "shorthand" --disable-env-management option. +# If the shorthand flag is provided, then it takes precendence over the R and Python flags. +# This callback also inverts the --disable-env-management-r and +# --disable-env-management-py boolean flags if they are provided, +# otherwise returns None. This is so that we can pass the +# non-negative (env_management_r, env_management_py) args to our API functions, +# which is more consistent when writing these values to the manifest. +def env_management_callback(ctx, param, value) -> typing.Optional[bool]: + # eval the shorthand flag if it was provided + disable_env_management = ctx.params.get('disable_env_management') + if disable_env_management is not None: + value = disable_env_management + + # invert value if it is defined. + if value is not None: + return not value + return value + + +def runtime_environment_args(func): + @click.option( + "--image", + "-I", + help="Target image to be used during content build and execution. " + "This option is only applicable if the Connect server is configured to use off-host execution.", + ) + @click.option( + "--disable-env-management", + is_flag=True, + is_eager=True, + default=None, + help="Shorthand to disable environment management for both Python and R.", + ) + @click.option( + "--disable-env-management-py", + "env_management_py", + is_flag=True, + default=None, + help="Disable Python environment management for this bundle. " + "Connect will not create an environment or install packages. An administrator must install the " + "required packages in the correct Python environment on the Connect server.", + callback=env_management_callback, + ) + @click.option( + "--disable-env-management-r", + "env_management_r", + is_flag=True, + default=None, + help="Disable R environment management for this bundle. " + "Connect will not create an environment or install packages. An administrator must install the " + "required packages in the correct R environment on the Connect server.", + callback=env_management_callback, + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + @click.group(no_args_is_help=True) @click.option("--future", "-u", is_flag=True, hidden=True, help="Enables future functionality.") @@ -750,6 +809,7 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): ) @server_args @content_args +@runtime_environment_args @click.option( "--static", "-S", @@ -786,12 +846,6 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): @click.option( "--hide-tagged-input", is_flag=True, default=False, help="Hide input code cells with the 'hide_input' tag" ) -@click.option( - "--image", - "-I", - help="Target image to be used during content execution (only applicable if the Posit Connect " - "server is configured to use off-host execution)", -) @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( "extra_files", @@ -819,6 +873,9 @@ def deploy_notebook( hide_tagged_input: bool, env_vars: typing.Dict[str, str], image: str, + disable_env_management: bool, + env_management_py: bool, + env_management_r: bool, ): kwargs = locals() set_verbosity(verbose) @@ -845,6 +902,8 @@ def deploy_notebook( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) else: ce.make_bundle( @@ -855,6 +914,8 @@ def deploy_notebook( hide_all_input, hide_tagged_input, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) ce.deploy_bundle().save_deployed_info().emit_task_log() @@ -868,6 +929,7 @@ def deploy_notebook( ) @server_args @content_args +@runtime_environment_args @click.option( "--entrypoint", "-e", @@ -904,12 +966,6 @@ def deploy_notebook( 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", @@ -925,6 +981,9 @@ def deploy_voila( extra_files=None, exclude=None, image: str = "", + disable_env_management: bool = None, + env_management_py: bool = None, + env_management_r: bool = None, title: str = None, env_vars: typing.Dict[str, str] = None, verbose: bool = False, @@ -956,6 +1015,8 @@ def deploy_voila( force_generate, environment, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, multi_notebook=multi_notebook, ).deploy_bundle().save_deployed_info().emit_task_log() @@ -1030,6 +1091,7 @@ def deploy_manifest( ) @server_args @content_args +@runtime_environment_args @click.option( "--exclude", "-x", @@ -1061,12 +1123,6 @@ def deploy_manifest( 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 Posit Connect " - "server is configured to use off-host execution)", -) @click.argument("file_or_directory", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( "extra_files", @@ -1092,6 +1148,9 @@ def deploy_quarto( extra_files, env_vars: typing.Dict[str, str], image: str, + disable_env_management: bool, + env_management_py: bool, + env_management_r: bool, ): kwargs = locals() set_verbosity(verbose) @@ -1137,6 +1196,8 @@ def deploy_quarto( inspect, environment, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) .deploy_bundle() .save_deployed_info() @@ -1237,6 +1298,7 @@ def generate_deploy_python(app_mode, alias, min_version): @server_args @content_args @cloud_shinyapps_args + @runtime_environment_args @click.option( "--entrypoint", "-e", @@ -1276,12 +1338,6 @@ def generate_deploy_python(app_mode, alias, min_version): 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 Posit Connect " - "server is configured to use off-host execution)", - ) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( "extra_files", @@ -1310,6 +1366,9 @@ def deploy_app( visibility: typing.Optional[str], env_vars: typing.Dict[str, str], image: str, + disable_env_management: bool, + env_management_py: bool, + env_management_r: bool, account: str = None, token: str = None, secret: str = None, @@ -1337,6 +1396,8 @@ def deploy_app( extra_files, exclude, image=image, + env_management_py=env_management_py, + env_management_r=env_management_r, ) .deploy_bundle() .save_deployed_info() @@ -1422,18 +1483,13 @@ def write_manifest(): @click.option("--hide-all-input", is_flag=True, default=None, 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.option( - "--image", - "-I", - help="Target image to be used during content execution (only applicable if the Posit Connect " - "server is configured to use off-host execution)", -) @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), ) +@runtime_environment_args def write_manifest_notebook( overwrite, python, @@ -1443,6 +1499,9 @@ def write_manifest_notebook( file, extra_files, image, + disable_env_management, + env_management_py, + env_management_r, hide_all_input=None, hide_tagged_input=None, ): @@ -1470,6 +1529,8 @@ def write_manifest_notebook( hide_all_input, hide_tagged_input, image, + env_management_py, + env_management_r, ) if environment_file_exists and not force_generate: @@ -1506,12 +1567,6 @@ def write_manifest_notebook( 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", @@ -1535,6 +1590,7 @@ def write_manifest_notebook( is_flag=True, help=("Set the manifest for multi-notebook mode."), ) +@runtime_environment_args def write_manifest_voila( path: str, entrypoint: str, @@ -1545,6 +1601,9 @@ def write_manifest_voila( extra_files, exclude, image, + disable_env_management, + env_management_py, + env_management_r, multi_notebook, ): set_verbosity(verbose) @@ -1581,6 +1640,8 @@ def write_manifest_voila( exclude, force_generate, image, + env_management_py, + env_management_r, multi_notebook, ) @@ -1629,18 +1690,13 @@ def write_manifest_voila( 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 Posit Connect " - "server is configured to use off-host execution)", -) @click.argument("file_or_directory", 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), ) +@runtime_environment_args def write_manifest_quarto( overwrite, exclude, @@ -1651,6 +1707,9 @@ def write_manifest_quarto( file_or_directory, extra_files, image, + disable_env_management, + env_management_py, + env_management_r, ): set_verbosity(verbose) @@ -1695,6 +1754,8 @@ def write_manifest_quarto( extra_files, exclude, image, + env_management_py, + env_management_r, ) @@ -1748,18 +1809,13 @@ def generate_write_manifest_python(app_mode, alias): 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 Posit Connect " - "server is configured to use off-host execution)", - ) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( "extra_files", nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), ) + @runtime_environment_args def manifest_writer( overwrite, entrypoint, @@ -1771,6 +1827,9 @@ def manifest_writer( directory, extra_files, image, + disable_env_management, + env_management_py, + env_management_r, ): _write_framework_manifest( overwrite, @@ -1784,6 +1843,8 @@ def manifest_writer( extra_files, app_mode, image, + env_management_py, + env_management_r, ) return manifest_writer @@ -1810,6 +1871,8 @@ def _write_framework_manifest( extra_files, app_mode, image, + env_management_py, + env_management_r, ): """ A common function for writing manifests for APIs as well as Dash, Streamlit, and Bokeh apps. @@ -1827,6 +1890,10 @@ def _write_framework_manifest( :param extra_files: any extra files that should be included. :param app_mode: the app mode to use. :param image: an optional docker image for off-host execution. + :param env_management_py: False prevents Connect from managing the Python environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. + :param env_management_r: False prevents Connect from managing the R environment for this bundle. + The server administrator is responsible for installing packages in the runtime environment. Default = None. """ set_verbosity(verbose) @@ -1851,6 +1918,8 @@ def _write_framework_manifest( extra_files, exclude, image, + env_management_py, + env_management_r, ) if environment_file_exists and not force_generate: diff --git a/tests/test_bundle.py b/tests/test_bundle.py index d0de581b..d87ff20a 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -74,7 +74,8 @@ def test_make_notebook_source_bundle1(self): # the kernel environment and not the notebook server environment. environment = detect_environment(directory) with make_notebook_source_bundle( - nb_path, environment, None, hide_all_input=False, hide_tagged_input=False, image=None + nb_path, environment, None, hide_all_input=False, hide_tagged_input=False, + image=None, env_management_py=None, env_management_r=None, ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) self.assertEqual( @@ -145,6 +146,8 @@ def test_make_notebook_source_bundle2(self): hide_all_input=False, hide_tagged_input=False, image="rstudio/connect:bionic", + env_management_py=False, + env_management_r=False, ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) self.assertEqual( @@ -196,7 +199,13 @@ def test_make_notebook_source_bundle2(self): "package_file": "requirements.txt", }, }, - "environment": {"image": "rstudio/connect:bionic"}, + "environment": { + "image": "rstudio/connect:bionic", + "environment_management": { + "python": False, + "r": False, + } + }, "files": { "dummy.ipynb": { "checksum": ipynb_hash, @@ -554,20 +563,76 @@ def test_make_source_manifest(self): # quarto_inspection=None, # type: typing.Optional[typing.Dict[str, typing.Any]] # No optional parameters - manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, None) + manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None) self.assertEqual( manifest, {"version": 1, "metadata": {"appmode": "python-api"}, "files": {}}, ) # include image parameter - manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, "rstudio/connect:bionic") + manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, + image="rstudio/connect:bionic") self.assertEqual( manifest, { "version": 1, "metadata": {"appmode": "python-api"}, - "environment": {"image": "rstudio/connect:bionic"}, + "environment": { + "image": "rstudio/connect:bionic", + }, + "files": {}, + }, + ) + + # include env_management_py parameter + manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, + env_management_py=False) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "python-api"}, + "environment": { + "environment_management": { + "python": False + } + }, + "files": {}, + }, + ) + + # include env_management_r parameter + manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, + env_management_r=False) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "python-api"}, + "environment": { + "environment_management": { + "r": False + } + }, + "files": {}, + }, + ) + + # include all runtime environment parameters + manifest = make_source_manifest(AppModes.PYTHON_API, None, None, None, + image="rstudio/connect:bionic", env_management_py=False, env_management_r=False) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "python-api"}, + "environment": { + "image": "rstudio/connect:bionic", + "environment_management": { + "r": False, + "python": False + } + }, "files": {}, }, ) @@ -893,7 +958,7 @@ def test_make_html_manifest(self): # image=None, # type: str # No optional parameters - manifest = make_html_manifest("abc.html", None) + manifest = make_html_manifest("abc.html") # print(manifest) self.assertEqual( manifest, @@ -907,7 +972,8 @@ def test_make_html_manifest(self): ) # include image parameter - manifest = make_html_manifest("abc.html", image="rstudio/connect:bionic") + manifest = make_html_manifest("abc.html", + image="rstudio/connect:bionic") # print(manifest) self.assertEqual( manifest, @@ -917,7 +983,73 @@ def test_make_html_manifest(self): "appmode": "static", "primary_html": "abc.html", }, - "environment": {"image": "rstudio/connect:bionic"}, + "environment": { + "image": "rstudio/connect:bionic", + }, + }, + ) + + # include env_management_py parameter + manifest = make_html_manifest("abc.html", + env_management_py=False) + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "static", + "primary_html": "abc.html", + }, + "environment": { + "environment_management": { + "python": False, + } + }, + }, + ) + + # include env_management_r parameter + manifest = make_html_manifest("abc.html", + env_management_r=False) + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "static", + "primary_html": "abc.html", + }, + "environment": { + "environment_management": { + "r": False, + } + }, + }, + ) + + # include all runtime environment parameters + manifest = make_html_manifest("abc.html", + image="rstudio/connect:bionic", + env_management_py=False, + env_management_r=False) + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "static", + "primary_html": "abc.html", + }, + "environment": { + "image": "rstudio/connect:bionic", + "environment_management": { + "python": False, + "r": False, + } + }, }, ) @@ -1880,6 +2012,82 @@ def test_create_html_manifest(): ) assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) + # check all runtime_environment vars + single_file_index_file_ans = { + "version": 1, + "metadata": {"appmode": "static", "primary_html": "index.html", "entrypoint": "index.html"}, + "files": {"index.html": {"checksum": index_hash}}, + "environment": { + "image": "rstudio/connect:bionic", + "environment_management": { + "python": False, + "r": False, + } + }, + } + manifest = create_html_manifest( + single_file_index_file, + None, + image="rstudio/connect:bionic", + env_management_py=False, + env_management_r=False, + ) + assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) + + # check image param + single_file_index_file_ans = { + "version": 1, + "metadata": {"appmode": "static", "primary_html": "index.html", "entrypoint": "index.html"}, + "files": {"index.html": {"checksum": index_hash}}, + "environment": { + "image": "rstudio/connect:bionic", + }, + } + manifest = create_html_manifest( + single_file_index_file, + None, + image="rstudio/connect:bionic", + env_management_py=None, + env_management_r=None, + ) + assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) + + # check env_management_py param + single_file_index_file_ans = { + "version": 1, + "metadata": {"appmode": "static", "primary_html": "index.html", "entrypoint": "index.html"}, + "files": {"index.html": {"checksum": index_hash}}, + "environment": { + "environment_management": { + "python": False, + } + }, + } + manifest = create_html_manifest( + single_file_index_file, + None, + env_management_py=False, + ) + assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) + + # check env_management_r param + single_file_index_file_ans = { + "version": 1, + "metadata": {"appmode": "static", "primary_html": "index.html", "entrypoint": "index.html"}, + "files": {"index.html": {"checksum": index_hash}}, + "environment": { + "environment_management": { + "r": False, + } + }, + } + manifest = create_html_manifest( + single_file_index_file, + None, + env_management_r=False, + ) + assert single_file_index_file_ans == json.loads(manifest.flattened_copy.json) + single_file_index_dir_ans = { "version": 1, "metadata": {"appmode": "static", "primary_html": "index.html", "entrypoint": "index.html"}, diff --git a/tests/test_main.py b/tests/test_main.py index d7a0b0be..9d159bd4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,6 +7,7 @@ import httpretty import pytest +import click from click.testing import CliRunner from rsconnect.json_web_token import SECRET_KEY_ENV @@ -22,7 +23,7 @@ require_connect, has_jwt_structure, ) -from rsconnect.main import cli +from rsconnect.main import cli, env_management_callback from rsconnect import VERSION @@ -746,6 +747,22 @@ def test_add_shinyapps_missing_options(self): os.environ["CONNECT_SERVER"] = original_server_value + def test_env_management_callback(self): + ctx = click.Context(cli) + + # env_management is always False when --disable-env-management is True + ctx.params = {'disable_env_management': True} + assert env_management_callback(ctx, None, None) is False + assert env_management_callback(ctx, None, True) is False + assert env_management_callback(ctx, None, False) is False + + # (env_management == not value) when --disable-env-management is None + ctx.params = {'disable_env_management': None} + assert env_management_callback(ctx, None, None) is None + assert env_management_callback(ctx, None, True) is False + assert env_management_callback(ctx, None, False) is True + + class TestBootstrap(TestCase): def setUp(self): self.mock_server = "http://localhost:8080"