diff --git a/.flake8 b/.flake8 index 197aeeff..8dece52f 100644 --- a/.flake8 +++ b/.flake8 @@ -15,3 +15,6 @@ exclude = .git,.venv,.venv2,.venv3,__pycache__,.cache extend_ignore = E203,E231,E302 # vim:filetype=dosini + +per-file-ignores = + tests/test_metadata.py: E501 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fd96ed..a90ae259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 2021.08.0 or later. Use `rsconnect deploy quarto` to deploy, or `rsconnect write-manifest quarto` to create a manifest file. +- An `image` command line option has been added to the `write-manifest` and + `deploy` commands to specify the target image to be used on the RStudio Connect + server during content execution. This is only supported for the `api`, `bokeh`, `dash`, + `fastapi`, `notebook`, `quarto` and `streamlit` sub-commands. It is only + applicable if the RStudio Connect server is configured to use off-host execution. + + ## [1.7.1] - 2022-02-15 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7c59aed..cb583593 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,51 @@ This project aims to uphold Python [community norms](https://www.python.org/psf/conduct/) and make use of [recommended tooling](https://packaging.python.org/guides/tool-recommendations/). +To get started, you'll want to: +- clone the repo into a project directory +- setup a virtual 3.5+ python environment in the project directory +- activate that virtual environment +- install the dependencies +- validate your build environment with some sample commands + +While there are several different tools and techniques you can use to accomplish the +steps listed above, the following is an example which uses `venv`. + +```bash +# Clone the repo +cd ~/dev +git clone https://github.com/rstudio/rsconnect-python.git +cd rsconnect-python +# Setup a virtual python environment +python3 -m venv .venv +# Activate the virtual environment +source .venv/bin/activate +# install our requirements into the virtual environment +pip install -r requirements.txt +# install rsconnect-python with a symbolic link to the locations repository, +# meaning any changes to code in there will automatically be reflected +pip install -e ./ +``` + ## Workflow -The [`test` job in the default GitHub Actions workflow](.github/workflows/main.yml) reflects a typical set of steps for -building and testing. +With your venv setup and active, as described previously, running rsconnect-python using your codebase is as simple as running the `rsconnect` command from the terminal. + +Typical makefile targets are: + +```bash +# verify code formats are correct +make fmt +# lint the codebase +make lint +# run the tests (w/ python 3.8) +make test +# run the tests with all versions of python +make all-tests +``` + +As another example, the [`test` job in the default GitHub Actions workflow](.github/workflows/main.yml) +uses some of these targets during the CI for building and testing. ## Proposing Change diff --git a/Makefile b/Makefile index b3f1026c..e4563d62 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,7 @@ deps-%: lint-%: $(RUNNER) 'black --check --diff rsconnect/' $(RUNNER) 'flake8 rsconnect/' + $(RUNNER) 'flake8 tests/' $(RUNNER) 'mypy -p rsconnect' .PHONY: lint-3.5 diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 24e3ebee..016c9c7e 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -37,7 +37,7 @@ from .environment import Environment, MakeEnvironment, EnvironmentException from .log import logger from .metadata import AppStore -from .models import AppModes +from .models import AppModes, AppMode import click from six.moves.urllib_parse import urlparse @@ -304,7 +304,7 @@ def check_server_capabilities(connect_server, capability_functions, details_sour raise api.RSConnectException(message) -def _make_deployment_name(connect_server, title, force_unique): +def _make_deployment_name(connect_server, title, force_unique) -> str: """ Produce a name for a deployment based on its title. It is assumed that the title is already defaulted and validated as appropriate (meaning the title @@ -527,26 +527,28 @@ def validate_quarto_engines(inspect): def write_quarto_manifest_json( - directory, - inspect, - app_mode=AppModes.STATIC_QUARTO, - environment=None, - extra_files=None, - excludes=None, -): + directory: str, + inspect: typing.Any, + app_mode: AppMode, + environment: Environment, + extra_files: typing.List[str], + excludes: typing.List[str], + image: str, +) -> None: """ Creates and writes a manifest.json file for the given Quarto project. :param directory: The directory containing the Quarto project. :param inspect: The parsed JSON from a 'quarto inspect' against the project. - :param app_mode: The application mode to assume. + :param app_mode: The application mode to assume (such as AppModes.STATIC_QUARTO) :param environment: The (optional) Python environment to use. :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 docker image to be specified for off-host execution (or None if no image is specified). """ extra_files = validate_extra_files(directory, extra_files) - manifest, _ = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes) + manifest, _ = make_quarto_manifest(directory, inspect, app_mode, image, environment, extra_files, excludes) manifest_path = join(directory, "manifest.json") write_manifest_json(manifest_path, manifest) @@ -562,20 +564,21 @@ def write_manifest_json(manifest_path, manifest): def deploy_jupyter_notebook( - connect_server, - file_name, - extra_files, - new=False, - app_id=None, - title=None, - static=False, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, - hide_all_input=False, - hide_tagged_input=False, -): + connect_server: api.RSConnectServer, + file_name: str, + extra_files: typing.List[str], + image: str, + new: bool, + app_id: int, + title: str, + static: bool, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, + hide_all_input: bool, + hide_tagged_input: bool, +) -> typing.Tuple[typing.Any, typing.List]: """ A function to deploy a Jupyter notebook to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -583,34 +586,38 @@ def deploy_jupyter_notebook( :param connect_server: the Connect server information. :param file_name: the Jupyter notebook file to deploy. :param extra_files: any extra files that should be included in the deploy. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. + :param image: an optional docker image for off-host execution, previous default = None. + :param new: a flag indicating a new deployment, previous default = False. + :param app_id: the ID of an existing application to deploy new files for, previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. :param static: a flag noting whether the notebook should be deployed as a static - HTML page or as a render-able document with sources. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + HTML page or as a render-able document with sources. Previous default = False. + :param python: the optional name of a Python executable, previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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 + of the return tuple. Previous default = None. + :param hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering + output. Previous default = False. :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. """ app_store = AppStore(file_name) - ( + (app_id, deployment_name, deployment_title, default_title, app_mode,) = gather_basic_deployment_info_for_notebook( + connect_server, + app_store, + file_name, + new, app_id, - deployment_name, - deployment_title, - default_title, - app_mode, - ) = gather_basic_deployment_info_for_notebook(connect_server, app_store, file_name, new, app_id, title, static) + title, + static, + ) python, environment = get_python_env_info( file_name, python, @@ -618,7 +625,7 @@ def deploy_jupyter_notebook( force_generate=force_generate, ) bundle = create_notebook_deployment_bundle( - file_name, extra_files, app_mode, python, environment, hide_all_input, hide_tagged_input + file_name, extra_files, app_mode, python, environment, image, True, hide_all_input, hide_tagged_input ) return _finalize_deploy( connect_server, @@ -635,17 +642,17 @@ def deploy_jupyter_notebook( def _finalize_deploy( - connect_server, - app_store, - file_name, - app_id, - app_mode, - name, - title, - title_is_default, - bundle, - log_callback, -): + connect_server: api.RSConnectServer, + app_store: AppStore, + file_name: str, + app_id: int, + app_mode: AppMode, + name: str, + title: str, + title_is_default: bool, + bundle: typing.IO[bytes], + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A common function to finish up the deploy process once all the data (bundle included) has been resolved. @@ -663,10 +670,11 @@ def _finalize_deploy( (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 image: an optional docker image for off-host execution. :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. """ - app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle) + app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle, None) app_url, log_lines, _ = spool_deployment_log(connect_server, app, log_callback) app_store.set( connect_server.url, @@ -680,7 +688,7 @@ def _finalize_deploy( return app_url, log_lines -def fake_module_file_from_directory(directory): +def fake_module_file_from_directory(directory: str): """ Takes a directory and invents a properly named file that though possibly fake, can be used for other name/title derivation. @@ -694,19 +702,20 @@ def fake_module_file_from_directory(directory): def deploy_python_api( - connect_server, - directory, - extra_files, - excludes, - entry_point, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python WSGi API module to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -716,19 +725,20 @@ def deploy_python_api( :param extra_files: any extra files that should be included in the deploy. :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -739,6 +749,7 @@ def deploy_python_api( excludes, entry_point, gather_basic_deployment_info_for_api, + image, new, app_id, title, @@ -750,19 +761,20 @@ def deploy_python_api( def deploy_python_fastapi( - connect_server, - directory, - extra_files, - excludes, - entry_point, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python ASGI API module to RStudio Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -772,19 +784,20 @@ def deploy_python_fastapi( :param extra_files: any extra files that should be included in the deploy. :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -795,6 +808,7 @@ def deploy_python_fastapi( excludes, entry_point, gather_basic_deployment_info_for_fastapi, + image, new, app_id, title, @@ -806,19 +820,20 @@ def deploy_python_fastapi( def deploy_dash_app( - connect_server, - directory, - extra_files, - excludes, - entry_point, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Dash app module to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -828,19 +843,20 @@ def deploy_dash_app( :param extra_files: any extra files that should be included in the deploy. :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -851,6 +867,7 @@ def deploy_dash_app( excludes, entry_point, gather_basic_deployment_info_for_dash, + image, new, app_id, title, @@ -862,19 +879,20 @@ def deploy_dash_app( def deploy_streamlit_app( - connect_server, - directory, - extra_files, - excludes, - entry_point, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Streamlit app module to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -884,19 +902,20 @@ def deploy_streamlit_app( :param extra_files: any extra files that should be included in the deploy. :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -907,6 +926,7 @@ def deploy_streamlit_app( excludes, entry_point, gather_basic_deployment_info_for_streamlit, + image, new, app_id, title, @@ -918,19 +938,20 @@ def deploy_streamlit_app( def deploy_bokeh_app( - connect_server, - directory, - extra_files, - excludes, - entry_point, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python Bokeh app module to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -940,19 +961,20 @@ def deploy_bokeh_app( :param extra_files: any extra files that should be included in the deploy. :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False. :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -963,6 +985,7 @@ def deploy_bokeh_app( excludes, entry_point, gather_basic_deployment_info_for_bokeh, + image, new, app_id, title, @@ -974,20 +997,21 @@ def deploy_bokeh_app( def _deploy_by_python_framework( - connect_server, - directory, - extra_files, - excludes, - entry_point, - gatherer, - new=False, - app_id=None, - title=None, - python=None, - conda_mode=False, - force_generate=False, - log_callback=None, -): + connect_server: api.RSConnectServer, + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + gatherer: typing.Callable, + image: str, + new: bool, + app_id: int, + title: str, + python: str, + conda_mode: bool, + force_generate: bool, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Python WSGi API module to Connect. Depending on the files involved and network latency, this may take a bit of time. @@ -998,19 +1022,20 @@ def _deploy_by_python_framework( :param excludes: a sequence of glob patterns that will exclude matched files. :param entry_point: the module/executable object for the WSGi framework. :param gatherer: the function to use to gather basic information. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. - :param python: the optional name of a Python executable. - :param conda_mode: use conda to build an environment.yml - instead of conda, when conda is not supported on RStudio Connect (version<=1.8.0). + :param image: an optional docker image for off-host execution. Previous default = None. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. + :param python: the optional name of a Python executable. Previous default = None. + :param conda_mode: use conda to build an environment.yml instead of conda, when + conda is not supported on RStudio Connect (version<=1.8.0). Previous default = False :param force_generate: force generating "requirements.txt" or "environment.yml", - even if it already exists. + even if it already exists. Previous default = False :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -1030,7 +1055,9 @@ def _deploy_by_python_framework( conda_mode=conda_mode, force_generate=force_generate, ) - bundle = create_api_deployment_bundle(directory, extra_files, excludes, entry_point, app_mode, environment) + bundle = create_api_deployment_bundle( + directory, extra_files, excludes, entry_point, app_mode, environment, image, True + ) return _finalize_deploy( connect_server, app_store, @@ -1046,27 +1073,27 @@ def _deploy_by_python_framework( def deploy_by_manifest( - connect_server, - manifest_file_name, - new=False, - app_id=None, - title=None, - log_callback=None, -): + connect_server: api.RSConnectServer, + manifest_file_name: str, + new: bool, + app_id: int, + title: str, + log_callback: typing.Callable, +) -> typing.Tuple[str, typing.Union[list, None]]: """ A function to deploy a Jupyter notebook to Connect. Depending on the files involved and network latency, this may take a bit of time. :param connect_server: the Connect server information. :param manifest_file_name: the manifest file to deploy. - :param new: a flag to force this as a new deploy. - :param app_id: the ID of an existing application to deploy new files for. - :param title: an optional title for the deploy. If this is not provided, ne will - be generated. + :param new: a flag to force this as a new deploy. Previous default = False. + :param app_id: the ID of an existing application to deploy new files for. Previous default = None. + :param title: an optional title for the deploy. If this is not provided, one will + be generated. Previous default = None. :param log_callback: the callback to use to write the log to. If this is None (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. + of the return tuple. Previous 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. """ @@ -1077,7 +1104,8 @@ def deploy_by_manifest( deployment_title, default_title, app_mode, - package_manager, + _, + _, ) = gather_basic_deployment_info_from_manifest(connect_server, app_store, manifest_file_name, new, app_id, title) bundle = make_manifest_bundle(manifest_file_name) return _finalize_deploy( @@ -1094,7 +1122,15 @@ def deploy_by_manifest( ) -def gather_basic_deployment_info_for_notebook(connect_server, app_store, file_name, new, app_id, title, static): +def gather_basic_deployment_info_for_notebook( + connect_server: api.RSConnectServer, + app_store: AppStore, + file_name: str, + new: bool, + app_id: int, + title: str, + static: bool, +) -> typing.Tuple[int, str, str, bool, AppMode]: """ Helps to gather the necessary info for performing a deployment. @@ -1106,6 +1142,7 @@ def gather_basic_deployment_info_for_notebook(connect_server, app_store, file_na :param title: an optional title. If this isn't specified, a default title will be generated. :param static: a flag to note whether a static document should be deployed. + :param image: an optional docker image for off-host execution. :return: the app ID, name, title information and mode for the deployment. """ validate_file_is_notebook(file_name) @@ -1151,7 +1188,14 @@ def gather_basic_deployment_info_for_notebook(connect_server, app_store, file_na ) -def gather_basic_deployment_info_for_html(connect_server, app_store, path, new, app_id, title): +def gather_basic_deployment_info_for_html( + connect_server: api.RSConnectServer, + app_store: AppStore, + path: str, + new: bool, + app_id: int, + title: str, +) -> typing.Tuple[int, str, str, bool, AppMode]: """ Helps to gather the necessary info for performing a static html (re)deployment. @@ -1201,7 +1245,14 @@ def gather_basic_deployment_info_for_html(connect_server, app_store, path, new, ) -def gather_basic_deployment_info_from_manifest(connect_server, app_store, file_name, new, app_id, title): +def gather_basic_deployment_info_from_manifest( + connect_server: api.RSConnectServer, + app_store: AppStore, + file_name: str, + new: bool, + app_id: int, + title: str, +) -> typing.Tuple[int, str, str, bool, AppMode, str, str]: """ Helps to gather the necessary info for performing a deployment. @@ -1212,7 +1263,7 @@ def gather_basic_deployment_info_from_manifest(connect_server, app_store, file_n :param app_id: the ID of the app to redeploy. :param title: an optional title. If this isn't specified, a default title will be generated. - :return: the app ID, name, title information, mode, and package manager for the + :return: the app ID, name, title information, mode, package manager and image for the deployment. """ file_name = validate_manifest_file(file_name) @@ -1234,6 +1285,7 @@ def gather_basic_deployment_info_from_manifest(connect_server, app_store, file_n package_manager = source_manifest.get("python", {}).get("package_manager", {}).get("name", None) default_title = not bool(title) title = title or _default_title_from_manifest(source_manifest, file_name) + image = source_manifest.get("Environment", {}).get("image", None) return ( app_id, @@ -1242,10 +1294,18 @@ def gather_basic_deployment_info_from_manifest(connect_server, app_store, file_n default_title, app_mode, package_manager, + image, ) -def gather_basic_deployment_info_for_quarto(connect_server, app_store, directory, new, app_id, title): +def gather_basic_deployment_info_for_quarto( + connect_server: api.RSConnectServer, + app_store: AppStore, + directory: str, + new: bool, + app_id: int, + title: str, +) -> typing.Tuple[int, str, str, bool, AppMode]: """ Helps to gather the necessary info for performing a deployment. @@ -1298,12 +1358,20 @@ def gather_basic_deployment_info_for_quarto(connect_server, app_store, directory ) -def _generate_gather_basic_deployment_info_for_python(app_mode): +def _generate_gather_basic_deployment_info_for_python(app_mode: AppMode) -> typing.Callable: """ Generates function to gather the necessary info for performing a deployment by app mode """ - def gatherer(connect_server, app_store, directory, entry_point, new, app_id, title): + def gatherer( + connect_server: api.RSConnectServer, + app_store: AppStore, + directory: str, + entry_point: str, + new: bool, + app_id: int, + title: str, + ) -> typing.Tuple[str, int, str, str, bool, AppMode]: return _gather_basic_deployment_info_for_framework( connect_server, app_store, @@ -1326,8 +1394,15 @@ def gatherer(connect_server, app_store, directory, entry_point, new, app_id, tit def _gather_basic_deployment_info_for_framework( - connect_server, app_store, directory, entry_point, new, app_id, app_mode, title -): + connect_server: api.RSConnectServer, + app_store: AppStore, + directory: str, + entry_point: str, + new: bool, + app_id: int, + app_mode: AppMode, + title: str, +) -> typing.Tuple[str, int, str, str, bool, AppMode]: """ Helps to gather the necessary info for performing a deployment. @@ -1342,7 +1417,7 @@ def _gather_basic_deployment_info_for_framework( :param app_mode: the app mode to use. :param title: an optional title. If this isn't specified, a default title will be generated. - :return: the entry point, app ID, name, title and mode for the deployment. + :return: the entry point, app ID, name, title, and mode for the deployment. """ entry_point = validate_entry_point(entry_point, directory) @@ -1412,15 +1487,16 @@ 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, -): + file_name: str, + extra_files: typing.List[str], + app_mode: AppMode, + python: str, + environment: Environment, + image: str, + extra_files_need_validating: bool, + hide_all_input: bool, + hide_tagged_input: bool, +) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1429,12 +1505,15 @@ def create_notebook_deployment_bundle( :param app_mode: the mode of the app being deployed. :param python: information about the version of Python being used. :param environment: environmental information. + :param image: an optional docker image for off-host execution. Previous default = None. :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 + 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. + sure the names are properly qualified first. Previous default = True. + :param hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :param hide_tagged_input: If True, will hide input code cells with + the 'hide_input' tag when rendering output. Previous default = False. + :return: the bundle. """ validate_file_is_notebook(file_name) @@ -1444,24 +1523,27 @@ def create_notebook_deployment_bundle( if app_mode == AppModes.STATIC: try: - return make_notebook_html_bundle(file_name, python, hide_all_input, hide_tagged_input) + return make_notebook_html_bundle(file_name, python, image, hide_all_input, hide_tagged_input, None) 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, hide_all_input, hide_tagged_input) + return make_notebook_source_bundle( + file_name, environment, image, extra_files, hide_all_input, hide_tagged_input + ) def create_api_deployment_bundle( - directory, - extra_files, - excludes, - entry_point, - app_mode, - environment, - extra_files_need_validating=True, -): + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + entry_point: str, + app_mode: AppMode, + environment: Environment, + image: str, + extra_files_need_validating: bool, +) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1471,10 +1553,11 @@ def create_api_deployment_bundle( :param entry_point: the module/executable object for the WSGi framework. :param app_mode: the mode of the app being deployed. :param environment: environmental information. + :param image: an optional docker image for off-host execution. Previous default = None. :param extra_files_need_validating: a flag indicating whether the list of extra files should be validated or not. Part of validating includes qualifying each with the specified directory. If you provide False here, make sure the names - are properly qualified first. + are properly qualified first. Previous default = True. :return: the bundle. """ entry_point = validate_entry_point(entry_point, directory) @@ -1485,18 +1568,19 @@ 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) + return make_api_bundle(directory, entry_point, app_mode, environment, image, extra_files, excludes) def create_quarto_deployment_bundle( - directory, - extra_files, - excludes, - app_mode, - inspect, - environment, - extra_files_need_validating=True, -): + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], + app_mode: AppMode, + inspect: typing.Dict[str, typing.Any], + environment: Environment, + image: str, + extra_files_need_validating: bool, +) -> typing.IO[bytes]: """ Create an in-memory bundle, ready to deploy. @@ -1506,10 +1590,11 @@ def create_quarto_deployment_bundle( :param entry_point: the module/executable object for the WSGi framework. :param app_mode: the mode of the app being deployed. :param environment: environmental information. + :param image: an optional docker image for off-host execution. Previous default = None. :param extra_files_need_validating: a flag indicating whether the list of extra files should be validated or not. Part of validating includes qualifying each with the specified directory. If you provide False here, make sure the names - are properly qualified first. + are properly qualified first. Previous default = True. :return: the bundle. """ if extra_files_need_validating: @@ -1518,10 +1603,18 @@ def create_quarto_deployment_bundle( if app_mode is None: app_mode = AppModes.STATIC_QUARTO - return make_quarto_source_bundle(directory, inspect, app_mode, environment, extra_files, excludes) + return make_quarto_source_bundle(directory, inspect, app_mode, image, environment, extra_files, excludes) -def deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle, env_vars=None): +def deploy_bundle( + connect_server: api.RSConnectServer, + app_id: int, + name: str, + title: str, + title_is_default: bool, + bundle: typing.IO[bytes], + env_vars: typing.List[typing.Tuple[str, str]], +) -> typing.Dict[str, typing.Any]: """ Deploys the specified bundle. @@ -1555,14 +1648,15 @@ 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, - hide_all_input=False, - hide_tagged_input=False, -): + entry_point_file: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + force: bool, + hide_all_input: bool, + hide_tagged_input: bool, + image: str, +) -> None: """ Creates and writes a manifest.json file for the given notebook entry point file. If the related environment file (requirements.txt, environment.yml, etc.) doesn't @@ -1573,17 +1667,19 @@ def create_notebook_manifest_and_environment_file( :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. - :param extra_files: any extra files that should be included in the manifest. + 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 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 + already exists. Previous default = True. + :param hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :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. :return: """ if ( not write_notebook_manifest_json( - entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input, image ) or force ): @@ -1591,8 +1687,14 @@ def create_notebook_manifest_and_environment_file( def write_notebook_manifest_json( - entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input -): + entry_point_file: str, + environment: Environment, + app_mode: AppMode, + extra_files: typing.List[str], + hide_all_input: bool, + hide_tagged_input: bool, + image: str, +) -> bool: """ 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 @@ -1603,10 +1705,12 @@ def write_notebook_manifest_json( :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. - :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 + 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 hide_all_input: if True, will hide all input cells when rendering output. Previous default = False. + :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. :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ @@ -1621,7 +1725,7 @@ def write_notebook_manifest_json( if app_mode == AppModes.UNKNOWN: raise api.RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) - manifest_data = make_source_manifest(app_mode, environment, file_name) + manifest_data = make_source_manifest(app_mode, image, environment, file_name, None) manifest_add_file(manifest_data, file_name, directory) manifest_add_buffer(manifest_data, environment.filename, environment.contents) @@ -1634,14 +1738,15 @@ def write_notebook_manifest_json( def create_api_manifest_and_environment_file( - directory, - entry_point, - environment, - app_mode=AppModes.PYTHON_API, - extra_files=None, - excludes=None, - force=True, -): + directory: str, + entry_point: str, + environment: Environment, + image: str, + app_mode: AppMode, + extra_files: typing.List[str], + excludes: typing.List[str], + force: bool, +) -> None: """ Creates and writes a manifest.json file for the given Python API entry point. If the related environment file (requirements.txt, environment.yml, etc.) doesn't @@ -1651,25 +1756,30 @@ def create_api_manifest_and_environment_file( :param entry_point: the module/executable object for the WSGi framework. :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. - :param extra_files: any extra files that should be included in the manifest. - :param excludes: a sequence of glob patterns that will exclude matched files. + :param image: an optional docker image for off-host execution. Previous default = None. + :param app_mode: the application mode to assume. Previous default = AppModes.PYTHON_API. + :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 force: if True, forces the environment file to be written. even if it - already exists. + already exists. Previous default = True. :return: """ - if not write_api_manifest_json(directory, entry_point, environment, app_mode, extra_files, excludes) or force: + if ( + not write_api_manifest_json(directory, entry_point, environment, image, app_mode, extra_files, excludes) + or force + ): write_environment_file(environment, directory) def write_api_manifest_json( - directory, - entry_point, - environment, - app_mode=AppModes.PYTHON_API, - extra_files=None, - excludes=None, -): + directory: str, + entry_point: str, + environment: Environment, + image: str, + app_mode: AppMode, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> bool: """ 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 @@ -1679,14 +1789,15 @@ def write_api_manifest_json( :param entry_point: the module/executable object for the WSGi framework. :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. - :param extra_files: any extra files that should be included in the manifest. - :param excludes: a sequence of glob patterns that will exclude matched files. + :param image: an optional docker image for off-host execution. Previous default = None. + :param app_mode: the application mode to assume. Previous default = AppModes.PYTHON_API. + :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. :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) + manifest, _ = make_api_manifest(directory, entry_point, app_mode, environment, image, extra_files, excludes) manifest_path = join(directory, "manifest.json") write_manifest_json(manifest_path, manifest) @@ -1694,7 +1805,10 @@ def write_api_manifest_json( return exists(join(directory, environment.filename)) -def write_environment_file(environment, directory): +def write_environment_file( + environment: Environment, + directory: str, +) -> None: """ Writes the environment file (requirements.txt, environment.yml, etc.) to the specified directory. @@ -1708,7 +1822,9 @@ def write_environment_file(environment, directory): f.write(environment.contents) -def describe_manifest(file_name): +def describe_manifest( + file_name: str, +) -> typing.Tuple[str, str]: """ Determine the entry point and/or primary file from the given manifest file. If no entry point is recorded in the manifest, then None will be returned for diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index d5f7e76a..50dc8cb0 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -43,12 +43,12 @@ # noinspection SpellCheckingInspection def make_source_manifest( - app_mode, # type: AppMode - environment=None, # type: typing.Optional[Environment] - entrypoint=None, # type: typing.Optional[str] - quarto_inspection=None, # type: typing.Optional[typing.Dict[str, typing.Any]] -): - # type: (...) -> typing.Dict[str, typing.Any] + app_mode: AppMode, + image: str, + environment: Environment, + entrypoint: str, + quarto_inspection: typing.Dict[str, typing.Any], +) -> typing.Dict[str, typing.Any]: manifest = { "version": 1, @@ -90,6 +90,11 @@ def make_source_manifest( }, } + if image: + manifest["environment"] = { + "image": image, + } + manifest["files"] = {} return manifest @@ -172,8 +177,15 @@ def bundle_add_buffer(bundle, filename, contents): bundle.addfile(file_info, buf) -def write_manifest(relative_dir, nb_name, environment, output_dir, hide_all_input=False, hide_tagged_input=False): - # type: (...) -> typing.Tuple[list, list] +def write_manifest( + relative_dir: str, + nb_name: str, + environment: Environment, + output_dir: str, + hide_all_input: bool, + hide_tagged_input: bool, + image: str, +) -> 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.. @@ -182,7 +194,7 @@ def write_manifest(relative_dir, nb_name, environment, output_dir, hide_all_inpu Returns the list of filenames written. """ manifest_filename = "manifest.json" - manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name) + manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, image, environment, nb_name, None) if hide_all_input: if "jupyter" not in manifest: manifest["jupyter"] = {} @@ -245,13 +257,13 @@ def iter_files(): 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] + file: str, + environment: Environment, + image: str, + extra_files: typing.List[str], + hide_all_input: bool, + hide_tagged_input: bool, +) -> typing.IO[bytes]: """Create a bundle containing the specified notebook and python environment. Returns a file-like object containing the bundle tarball. @@ -261,7 +273,7 @@ def make_notebook_source_bundle( base_dir = dirname(file) nb_name = basename(file) - manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, environment, nb_name) + manifest = make_source_manifest(AppModes.JUPYTER_NOTEBOOK, image, environment, nb_name, None) if hide_all_input: if "jupyter" not in manifest: manifest["jupyter"] = {} @@ -299,21 +311,23 @@ def make_notebook_source_bundle( # def make_quarto_source_bundle( - directory, # type: str - inspect, # type: typing.Dict[str, typing.Any] - app_mode, # type: AppMode - environment=None, # type: Environment - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.IO[bytes] + directory: str, + inspect: typing.Dict[str, typing.Any], + app_mode: AppMode, + image: str, + environment: Environment, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.IO[bytes]: """ Create a bundle containing the specified Quarto content and (optional) python environment. Returns a file-like object containing the bundle tarball. """ - manifest, relevant_files = make_quarto_manifest(directory, inspect, app_mode, environment, extra_files, excludes) + manifest, relevant_files = make_quarto_manifest( + directory, inspect, app_mode, image, environment, extra_files, excludes + ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle: @@ -330,27 +344,37 @@ def make_quarto_source_bundle( return bundle_file -def make_html_manifest(filename): - # type: (str) -> dict +def make_html_manifest( + filename: str, + image: str, +) -> typing.Dict[str, typing.Any]: # noinspection SpellCheckingInspection - return { + manifest = { "version": 1, "metadata": { "appmode": "static", "primary_html": filename, }, } + if image: + manifest["environment"] = { + "image": image, + } + return manifest 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] + filename: str, + python: str, + image: str, + hide_all_input: bool, + hide_tagged_input: bool, + check_output: typing.Callable, # used to default to subprocess.check_output +) -> typing.IO[bytes]: # noinspection SpellCheckingInspection + if check_output is None: + check_output = subprocess.check_output + cmd = [ python, "-m", @@ -384,7 +408,7 @@ def make_notebook_html_bundle( bundle_add_buffer(bundle, filename, output) # manifest - manifest = make_html_manifest(filename) + manifest = make_html_manifest(filename, image) bundle_add_buffer(bundle, "manifest.json", json.dumps(manifest)) # rewind file pointer @@ -541,14 +565,14 @@ def _create_api_file_list( def make_api_manifest( - directory, # type: str - entry_point, # type: str - app_mode, # type: AppMode - environment, # type: Environment - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]] + directory: str, + entry_point: str, + app_mode: AppMode, + environment: Environment, + image: str, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: """ Makes a manifest for an API. @@ -556,6 +580,7 @@ def make_api_manifest( :param entry_point: the main entry point for the API. :param app_mode: the app mode to use. :param environment: the Python environment information. + :param image: an optional docker image for off-host execution. :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. :return: the manifest and a list of the files involved. @@ -564,7 +589,7 @@ def make_api_manifest( excludes = list(excludes or []) + ["bin/", "lib/"] relevant_files = _create_api_file_list(directory, environment.filename, extra_files, excludes) - manifest = make_source_manifest(app_mode, environment, entry_point) + manifest = make_source_manifest(app_mode, image, environment, entry_point, None) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -575,12 +600,12 @@ def make_api_manifest( def make_html_bundle_content( - path, # type: str - entrypoint, # type: str - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]] + path: str, + entrypoint: str, + image: str, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: """ Makes a manifest for static html deployment. @@ -634,7 +659,7 @@ def make_html_bundle_content( extra_files.remove(rel_path) relevant_files = sorted(file_list) - manifest = make_html_manifest(entrypoint) + manifest = make_html_manifest(entrypoint, image) for rel_path in relevant_files: manifest_add_file(manifest, rel_path, path) @@ -665,22 +690,23 @@ def infer_entrypoint(path, mimetype): def make_html_bundle( - path, # type: str - entry_point, # type: str - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.IO[bytes] + path: str, + entry_point: str, + image: str, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.IO[bytes]: """ Create an html bundle, given a path and a manifest. :param path: the file, or the directory containing the files to deploy. :param entry_point: the main entry point for the API. + :param image: an optional docker image for off-host execution. :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. :return: a file-like object containing the bundle tarball. """ - manifest, relevant_files = make_html_bundle_content(path, entry_point, extra_files, excludes) + manifest, relevant_files = make_html_bundle_content(path, entry_point, image, extra_files, excludes) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle: @@ -696,14 +722,14 @@ def make_html_bundle( def make_api_bundle( - directory, # type: str - entry_point, # type: str - app_mode, # type: AppMode - environment, # type: Environment - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.IO[bytes] + directory: str, + entry_point: str, + app_mode: AppMode, + environment: Environment, + image: str, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.IO[bytes]: """ Create an API bundle, given a directory path and a manifest. @@ -711,11 +737,14 @@ def make_api_bundle( :param entry_point: the main entry point for the API. :param app_mode: the app mode to use. :param environment: the Python environment information. + :param image: an optional docker image for off-host execution. :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. :return: a file-like object containing the bundle tarball. """ - manifest, relevant_files = make_api_manifest(directory, entry_point, app_mode, environment, extra_files, excludes) + manifest, relevant_files = make_api_manifest( + directory, entry_point, app_mode, environment, image, extra_files, excludes + ) bundle_file = tempfile.TemporaryFile(prefix="rsc_bundle") with tarfile.open(mode="w:gz", fileobj=bundle_file) as bundle: @@ -732,11 +761,10 @@ def make_api_bundle( def _create_quarto_file_list( - directory, # type: str - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.List[str] + directory: str, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.List[str]: """ Builds a full list of files under the given directory that should be included in a manifest or bundle. Extra files and excludes are relative to the given @@ -778,20 +806,21 @@ def _create_quarto_file_list( def make_quarto_manifest( - directory, # type: str - inspect, # type: typing.Dict[str, typing.Any] - app_mode, # type: AppMode - environment=None, # type: typing.Optional[Environment] - extra_files=None, # type: typing.Optional[typing.List[str]] - excludes=None, # type: typing.Optional[typing.List[str]] -): - # type: (...) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]] + directory: str, + quarto_inspection: typing.Dict[str, typing.Any], + app_mode: AppMode, + image: str, + environment: Environment, + extra_files: typing.List[str], + excludes: typing.List[str], +) -> typing.Tuple[typing.Dict[str, typing.Any], typing.List[str]]: """ Makes a manifest for a Quarto project. :param directory: The directory containing the Quarto project. - :param inspect: The parsed JSON from a 'quarto inspect' against the project. + :param quarto_inspection: The parsed JSON from a 'quarto inspect' against the project. :param app_mode: The application mode to assume. + :param image: an optional docker image for off-host execution. :param environment: The (optional) Python environment to use. :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. @@ -802,7 +831,7 @@ def make_quarto_manifest( excludes = list(excludes or []) + [".quarto"] - project_config = inspect.get("config", {}).get("project", {}) + project_config = quarto_inspection.get("config", {}).get("project", {}) output_dir = project_config.get("output-dir", None) if output_dir: excludes = excludes + [output_dir] @@ -817,8 +846,10 @@ def make_quarto_manifest( relevant_files = _create_quarto_file_list(directory, extra_files, excludes) manifest = make_source_manifest( app_mode, - environment=environment, - quarto_inspection=inspect, + image, + environment, + None, + quarto_inspection, ) for rel_path in relevant_files: diff --git a/rsconnect/main.py b/rsconnect/main.py index 8c18570e..6dfc7b4b 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -3,6 +3,7 @@ import json import os import sys +import typing import textwrap from os.path import abspath, dirname, exists, isdir, join @@ -601,11 +602,13 @@ def _deploy_bundle( :param title_is_default: a flag noting whether the title carries a defaulted value. :param bundle: the bundle to deploy. :param env_vars: list of NAME=VALUE pairs to be set as the app environment + :param image: an optional docker image for off-host execution. """ with cli_feedback("Uploading bundle"): app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle, env_vars) with cli_feedback("Saving deployment data"): + # Note we are NOT saving image into the deployment record for now. app_store.set( connect_server.url, abspath(primary_path), @@ -686,6 +689,12 @@ def _deploy_bundle( @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 RStudio 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", @@ -711,6 +720,7 @@ def deploy_notebook( hide_all_input, hide_tagged_input, env_vars, + image, ): set_verbosity(verbose) @@ -718,13 +728,15 @@ def deploy_notebook( app_store = AppStore(file) connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) extra_files = validate_extra_files(dirname(file), extra_files) - ( + (app_id, deployment_name, title, default_title, app_mode,) = gather_basic_deployment_info_for_notebook( + connect_server, + app_store, + file, + new, app_id, - deployment_name, title, - default_title, - app_mode, - ) = gather_basic_deployment_info_for_notebook(connect_server, app_store, file, new, app_id, title, static) + static, + ) click.secho(' Deploying %s to server "%s"' % (file, connect_server.url)) @@ -747,7 +759,7 @@ def deploy_notebook( with cli_feedback("Creating deployment bundle"): bundle = create_notebook_deployment_bundle( - file, extra_files, app_mode, python, environment, False, hide_all_input, hide_tagged_input + file, extra_files, app_mode, python, environment, image, False, hide_all_input, hide_tagged_input ) _deploy_bundle( connect_server, @@ -776,7 +788,19 @@ def deploy_notebook( @server_args @content_args @click.argument("file", type=click.Path(exists=True, dir_okay=True, file_okay=True)) -def deploy_manifest(name, server, api_key, insecure, cacert, new, app_id, title, verbose, file, env_vars): +def deploy_manifest( + name: str, + server: str, + api_key: str, + insecure: bool, + cacert: str, + new: bool, + app_id: int, + title: str, + verbose: bool, + file: str, + env_vars: typing.Dict[str, str], +): set_verbosity(verbose) with cli_feedback("Checking arguments"): @@ -791,7 +815,15 @@ def deploy_manifest(name, server, api_key, insecure, cacert, new, app_id, title, default_title, app_mode, package_manager, - ) = gather_basic_deployment_info_from_manifest(connect_server, app_store, file, new, app_id, title) + _, + ) = gather_basic_deployment_info_from_manifest( + connect_server, + app_store, + file, + new, + app_id, + title, + ) click.secho(' Deploying %s to server "%s"' % (file, connect_server.url)) @@ -872,6 +904,12 @@ def deploy_manifest(name, server, api_key, insecure, cacert, new, app_id, title, 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("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( "extra_files", @@ -895,6 +933,7 @@ def deploy_quarto( directory, extra_files, env_vars, + image, ): set_verbosity(verbose) @@ -903,13 +942,14 @@ def deploy_quarto( app_store = AppStore(module_file) connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) extra_files = validate_extra_files(directory, extra_files) - ( + (app_id, deployment_name, title, default_title, app_mode) = gather_basic_deployment_info_for_quarto( + connect_server, + app_store, + directory, + new, app_id, - deployment_name, title, - default_title, - app_mode, - ) = gather_basic_deployment_info_for_quarto(connect_server, app_store, directory, new, app_id, title) + ) click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url)) @@ -936,7 +976,10 @@ def deploy_quarto( _warn_on_ignored_requirements(directory, environment.filename) with cli_feedback("Creating deployment bundle"): - bundle = create_quarto_deployment_bundle(directory, extra_files, exclude, app_mode, inspect, environment, False) + bundle = create_quarto_deployment_bundle( + directory, extra_files, exclude, app_mode, inspect, environment, image, False + ) + _deploy_bundle( connect_server, app_store, @@ -995,6 +1038,7 @@ def deploy_html( entrypoint, extra_files, excludes, + image, ): set_verbosity(verbose) @@ -1002,19 +1046,20 @@ def deploy_html( connect_server = _validate_deploy_to_args(name, server, api_key, insecure, cacert) app_store = AppStore(path) - ( + (app_id, deployment_name, title, default_title, app_mode) = gather_basic_deployment_info_for_html( + connect_server, + app_store, + path, + new, app_id, - deployment_name, title, - default_title, - app_mode, - ) = gather_basic_deployment_info_for_html(connect_server, app_store, path, new, app_id, title) + ) click.secho(' Deploying %s to server "%s"' % (path, connect_server.url)) with cli_feedback("Creating deployment bundle"): try: - bundle = make_html_bundle(path, entrypoint, extra_files, excludes) + bundle = make_html_bundle(path, entrypoint, image, extra_files, excludes) except IOError as error: msg = "Unable to include the file %s in the bundle: %s" % ( error.filename, @@ -1089,6 +1134,12 @@ 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 RStudio 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", @@ -1113,6 +1164,7 @@ def deploy_app( directory, extra_files, env_vars, + image, ): _deploy_by_framework( name, @@ -1139,6 +1191,7 @@ def deploy_app( AppModes.STREAMLIT_APP: gather_basic_deployment_info_for_streamlit, AppModes.BOKEH_APP: gather_basic_deployment_info_for_bokeh, }[app_mode], + image, ) return deploy_app @@ -1172,6 +1225,7 @@ def _deploy_by_framework( extra_files, env_vars, gatherer, + image, ): """ A common function for deploying APIs, as well as Dash, Streamlit, and Bokeh apps. @@ -1195,6 +1249,7 @@ def _deploy_by_framework( :param directory: the directory of the thing to deploy. :param extra_files: any extra files that should be included. :param gatherer: the function to use to gather basic information. + :param image: an optional docker image for off-host execution. """ set_verbosity(verbose) @@ -1228,7 +1283,9 @@ def _deploy_by_framework( _warn_on_ignored_requirements(directory, environment.filename) with cli_feedback("Creating deployment bundle"): - bundle = create_api_deployment_bundle(directory, extra_files, exclude, entrypoint, app_mode, environment, False) + bundle = create_api_deployment_bundle( + directory, extra_files, exclude, entrypoint, app_mode, environment, image, False + ) _deploy_bundle( connect_server, @@ -1309,6 +1366,12 @@ def write_manifest(): @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.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("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( "extra_files", @@ -1316,7 +1379,16 @@ def write_manifest(): type=click.Path(exists=True, dir_okay=False, file_okay=True), ) def write_manifest_notebook( - overwrite, python, conda, force_generate, verbose, file, extra_files, hide_all_input=None, hide_tagged_input=None + overwrite, + python, + conda, + force_generate, + verbose, + file, + extra_files, + image=None, # type: str + hide_all_input=None, + hide_tagged_input=None, ): set_verbosity(verbose) with cli_feedback("Checking arguments"): @@ -1342,6 +1414,7 @@ def write_manifest_notebook( extra_files, hide_all_input, hide_tagged_input, + image, ) if environment_file_exists and not force_generate: @@ -1396,6 +1469,12 @@ 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("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( "extra_files", @@ -1411,6 +1490,7 @@ def write_manifest_quarto( verbose, directory, extra_files, + image, ): set_verbosity(verbose) with cli_feedback("Checking arguments"): @@ -1450,6 +1530,7 @@ def write_manifest_quarto( environment, extra_files, exclude, + image, ) @@ -1503,6 +1584,12 @@ 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 RStudio 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", @@ -1519,6 +1606,7 @@ def manifest_writer( verbose, directory, extra_files, + image, ): _write_framework_manifest( overwrite, @@ -1531,6 +1619,7 @@ def manifest_writer( directory, extra_files, app_mode, + image, ) return manifest_writer @@ -1555,6 +1644,7 @@ def _write_framework_manifest( directory, extra_files, app_mode, + image, ): """ A common function for writing manifests for APIs as well as Dash, Streamlit, and Bokeh apps. @@ -1571,6 +1661,7 @@ def _write_framework_manifest( :param directory: the directory of the thing to deploy. :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. """ set_verbosity(verbose) @@ -1589,7 +1680,13 @@ def _write_framework_manifest( with cli_feedback("Creating manifest.json"): environment_file_exists = write_api_manifest_json( - directory, entrypoint, environment, app_mode, extra_files, exclude + directory, + entrypoint, + environment, + image, + app_mode, + extra_files, + exclude, ) if environment_file_exists and not force_generate: diff --git a/tests/test_actions.py b/tests/test_actions.py index f90ce3db..667f8f5c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -14,8 +14,6 @@ import pytest -from funcsigs import signature - import rsconnect.actions from rsconnect import api @@ -220,83 +218,14 @@ def test_deploy_python_api_validates(self): directory = get_api_path("flask") server = RSConnectServer("https://www.bogus.com", "bogus") with self.assertRaises(RSConnectException): - deploy_python_api(server, directory, [], [], "bogus") - - def test_deploy_dash_app_signature(self): - self.assertEqual( - str(signature(deploy_dash_app)), - "({})".format( - ", ".join( - [ - "connect_server", - "directory", - "extra_files", - "excludes", - "entry_point", - "new=False", - "app_id=None", - "title=None", - "python=None", - "conda_mode=False", - "force_generate=False", - "log_callback=None", - ] - ) - ), - ) + deploy_python_api(server, directory, [], [], "bogus", None, False, None, None, None, False, False, None) def test_deploy_dash_app_docs(self): self.assertTrue("Dash app" in deploy_dash_app.__doc__) - def test_deploy_streamlit_app_signature(self): - self.assertEqual( - str(signature(deploy_streamlit_app)), - "({})".format( - ", ".join( - [ - "connect_server", - "directory", - "extra_files", - "excludes", - "entry_point", - "new=False", - "app_id=None", - "title=None", - "python=None", - "conda_mode=False", - "force_generate=False", - "log_callback=None", - ] - ) - ), - ) - def test_deploy_streamlit_app_docs(self): self.assertTrue("Streamlit app" in deploy_streamlit_app.__doc__) - def test_deploy_bokeh_app_signature(self): - self.assertEqual( - str(signature(deploy_bokeh_app)), - "({})".format( - ", ".join( - [ - "connect_server", - "directory", - "extra_files", - "excludes", - "entry_point", - "new=False", - "app_id=None", - "title=None", - "python=None", - "conda_mode=False", - "force_generate=False", - "log_callback=None", - ] - ) - ), - ) - def test_deploy_bokeh_app_docs(self): self.assertTrue("Bokeh app" in deploy_bokeh_app.__doc__) @@ -311,17 +240,17 @@ def test_gather_basic_deployment_info_for_api_validates(self): def test_create_notebook_deployment_bundle_validates(self): file_name = get_dir(join("pip1", "requirements.txt")) with self.assertRaises(RSConnectException): - create_notebook_deployment_bundle(file_name, [], None, None, None) + create_notebook_deployment_bundle(file_name, [], None, None, None, None, True, False, False) file_name = get_dir(join("pip1", "dummy.ipynb")) with self.assertRaises(RSConnectException): - create_notebook_deployment_bundle(file_name, ["bogus"], None, None, None) + create_notebook_deployment_bundle(file_name, ["bogus"], None, None, None, None, True, False, False) def test_create_api_deployment_bundle_validates(self): directory = get_api_path("flask") with self.assertRaises(RSConnectException): - create_api_deployment_bundle(directory, [], [], "bogus:bogus:bogus", None, None) + create_api_deployment_bundle(directory, [], [], "bogus:bogus:bogus", None, None, None, None) with self.assertRaises(RSConnectException): - create_api_deployment_bundle(directory, ["bogus"], [], "app:app", None, None) + create_api_deployment_bundle(directory, ["bogus"], [], "app:app", MakeEnvironment(), None, None, True) def test_inspect_environment(self): environment = inspect_environment(sys.executable, get_dir("pip1")) diff --git a/tests/test_api.py b/tests/test_api.py index b25d0d5a..ec12637d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,6 @@ from unittest import TestCase -from rsconnect.api import RSConnectException, RSConnect +from rsconnect.api import RSConnect class TestAPI(TestCase): diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 871e4a8f..a4f47827 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -2,6 +2,7 @@ import json import sys import tarfile +import tempfile from unittest import TestCase from os.path import dirname, join @@ -14,7 +15,12 @@ make_notebook_source_bundle, keep_manifest_specified_file, to_bytes, + make_source_manifest, + make_quarto_manifest, + make_html_manifest, ) +from rsconnect.models import AppModes +from rsconnect.environment import Environment from .utils import get_dir @@ -44,7 +50,7 @@ def test_source_bundle1(self): # runs in the notebook server. We need the introspection to run in # the kernel environment and not the notebook server environment. environment = detect_environment(directory) - with make_notebook_source_bundle(nb_path, environment) as bundle, tarfile.open( + with make_notebook_source_bundle(nb_path, environment, None, None, False, False) as bundle, tarfile.open( mode="r:gz", fileobj=bundle ) as tar: @@ -108,9 +114,9 @@ def test_source_bundle2(self): # the kernel environment and not the notebook server environment. environment = detect_environment(directory) - with make_notebook_source_bundle(nb_path, environment, extra_files=["data.csv"]) as bundle, tarfile.open( - mode="r:gz", fileobj=bundle - ) as tar: + with make_notebook_source_bundle( + nb_path, environment, "rstudio/connect:bionic", ["data.csv"], False, False + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: names = sorted(tar.getnames()) self.assertEqual( @@ -158,6 +164,7 @@ def test_source_bundle2(self): "package_file": "requirements.txt", }, }, + "environment": {"image": "rstudio/connect:bionic"}, "files": { "dummy.ipynb": { "checksum": ipynb_hash, @@ -212,7 +219,7 @@ def do_test_html_bundle(self, directory): self.maxDiff = 5000 nb_path = join(directory, "dummy.ipynb") - bundle = make_notebook_html_bundle(nb_path, sys.executable) + bundle = make_notebook_html_bundle(nb_path, sys.executable, None, False, False, None) tar = tarfile.open(mode="r:gz", fileobj=bundle) @@ -268,3 +275,302 @@ def test_manifest_bundle(self): manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) manifest_names = sorted(filter(keep_manifest_specified_file, manifest["files"].keys())) self.assertEqual(tar_names, manifest_names) + + def test_make_source_manifest(self): + # Verify the optional parameters + # image=None, # type: str + # environment=None, # type: typing.Optional[Environment] + # entrypoint=None, # type: typing.Optional[str] + # 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) + self.assertEqual( + manifest, + {"version": 1, "metadata": {"appmode": "python-api"}, "files": {}}, + ) + + # include image parameter + manifest = make_source_manifest(AppModes.PYTHON_API, "rstudio/connect:bionic", None, None, None) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "python-api"}, + "environment": {"image": "rstudio/connect:bionic"}, + "files": {}, + }, + ) + + # include environment parameter + manifest = make_source_manifest( + AppModes.PYTHON_API, + None, + Environment( + conda=None, + contents="", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="22.0.4", + python="3.9.12", + source="file", + ), + None, + None, + ) + self.assertEqual( + manifest, + { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "python-api"}, + "python": { + "version": "3.9.12", + "package_manager": {"name": "pip", "version": "22.0.4", "package_file": "requirements.txt"}, + }, + "files": {}, + }, + ) + + # include entrypoint parameter + manifest = make_source_manifest( + AppModes.PYTHON_API, + None, + None, + "main.py", + None, + ) + # print(manifest) + self.assertEqual( + manifest, + {"version": 1, "metadata": {"appmode": "python-api", "entrypoint": "main.py"}, "files": {}}, + ) + + # include quarto_inspection parameter + manifest = make_source_manifest( + AppModes.PYTHON_API, + None, + None, + None, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + ) + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "python-api", + }, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "files": {}, + }, + ) + + def test_make_quarto_manifest(self): + temp = tempfile.mkdtemp() + + # Verify the optional parameters + # image=None, # type: str + # environment=None, # type: typing.Optional[Environment] + # extra_files=None, # type: typing.Optional[typing.List[str]] + # excludes=None, # type: typing.Optional[typing.List[str]] + + # No optional parameters + manifest, _ = make_quarto_manifest( + temp, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + AppModes.SHINY_QUARTO, + None, + None, + None, + None, + ) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "quarto-shiny"}, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "files": {}, + }, + ) + + # include image parameter + manifest, _ = make_quarto_manifest( + temp, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + AppModes.SHINY_QUARTO, + "rstudio/connect:bionic", + None, + None, + None, + ) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "quarto-shiny"}, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "environment": {"image": "rstudio/connect:bionic"}, + "files": {}, + }, + ) + + # Files used within this test + fp = open(join(temp, "requirements.txt"), "w") + fp.write("dash\n") + fp.write("pandas\n") + fp.close() + + # include environment parameter + manifest, _ = make_quarto_manifest( + temp, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + AppModes.SHINY_QUARTO, + None, + Environment( + conda=None, + contents="", + error=None, + filename="requirements.txt", + locale="en_US.UTF-8", + package_manager="pip", + pip="22.0.4", + python="3.9.12", + source="file", + ), + None, + None, + ) + self.assertEqual( + manifest, + { + "version": 1, + "locale": "en_US.UTF-8", + "metadata": {"appmode": "quarto-shiny"}, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "python": { + "version": "3.9.12", + "package_manager": {"name": "pip", "version": "22.0.4", "package_file": "requirements.txt"}, + }, + "files": {"requirements.txt": {"checksum": "6f83f7f33bf6983dd474ecbc6640a26b"}}, + }, + ) + + # include extra_files parameter + fp = open(join(temp, "a"), "w") + fp.write("This is file a\n") + fp.close() + fp = open(join(temp, "b"), "w") + fp.write("This is file b\n") + fp.close() + fp = open(join(temp, "c"), "w") + fp.write("This is file c\n") + fp.close() + manifest, _ = make_quarto_manifest( + temp, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + AppModes.SHINY_QUARTO, + None, + None, + ["a", "b", "c"], + None, + ) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "quarto-shiny"}, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "files": { + "a": {"checksum": "4a3eb92956aa3e16a9f0a84a43c943e7"}, + "b": {"checksum": "b249e5b536d30e6282cea227f3a73669"}, + "c": {"checksum": "53b36f1d5b6f7fb2cfaf0c15af7ffb2d"}, + "requirements.txt": {"checksum": "6f83f7f33bf6983dd474ecbc6640a26b"}, + }, + }, + ) + + # include excludes parameter + manifest, _ = make_quarto_manifest( + temp, + { + "quarto": {"version": "0.9.16"}, + "engines": ["jupyter"], + "config": {"project": {"title": "quarto-proj-py"}, "editor": "visual", "language": {}}, + }, + AppModes.SHINY_QUARTO, + None, + None, + ["a", "b", "c"], + ["requirements.txt"], + ) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": {"appmode": "quarto-shiny"}, + "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, + "files": { + "a": {"checksum": "4a3eb92956aa3e16a9f0a84a43c943e7"}, + "b": {"checksum": "b249e5b536d30e6282cea227f3a73669"}, + "c": {"checksum": "53b36f1d5b6f7fb2cfaf0c15af7ffb2d"}, + }, + }, + ) + + def test_make_html_manifest(self): + # Verify the optional parameters + # image=None, # type: str + + # No optional parameters + manifest = make_html_manifest("abc.html", None) + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "static", + "primary_html": "abc.html", + }, + }, + ) + + # include image parameter + manifest = make_html_manifest("abc.html", image="rstudio/connect:bionic") + # print(manifest) + self.assertEqual( + manifest, + { + "version": 1, + "metadata": { + "appmode": "static", + "primary_html": "abc.html", + }, + "environment": {"image": "rstudio/connect:bionic"}, + }, + ) diff --git a/tests/test_environment.py b/tests/test_environment.py index 83ace7d8..d684dca1 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -5,7 +5,6 @@ from os.path import dirname, join from rsconnect.environment import ( - Environment, EnvironmentException, MakeEnvironment, detect_environment, diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 884fff88..ffcefe48 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,3 +1,4 @@ +import shutil import tempfile from unittest import TestCase @@ -10,9 +11,19 @@ class TestServerMetadata(TestCase): def setUp(self): - self.server_store = ServerStore() + # Use temporary stores, to keep each test isolated + self.tempDir = tempfile.mkdtemp() + self.server_store = ServerStore(base_dir=self.tempDir) + self.server_store_path = join(self.tempDir, "servers.json") + self.assertFalse(exists(self.server_store_path)) + self.server_store.set("foo", "http://connect.local", "notReallyAnApiKey", ca_data="/certs/connect") self.server_store.set("bar", "http://connect.remote", "differentApiKey", insecure=True) + self.assertEqual(len(self.server_store.get_all_servers()), 2, "Unexpected servers after setup") + + def tearDown(self): + # clean up our temp test directory created with tempfile.mkdtemp() + shutil.rmtree(self.tempDir) def test_add(self): self.assertEqual( @@ -133,7 +144,7 @@ def test_save_and_load(self): self.assertEqual(server_store.get_all_servers(), server_store2.get_all_servers()) def test_get_path(self): - self.assertIn("rsconnect-python", self.server_store.get_path()) + self.assertIn("servers.json", self.server_store.get_path()) class TestAppMetadata(TestCase):