diff --git a/README.md b/README.md index 5c1a6529..b3931b76 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Content"](#deploying-r-or-other-content) for details. ## Deploying Python Content to RStudio Connect RStudio Connect supports the deployment of Jupyter notebooks, Python APIs (such as -`flask`-based) and apps (such as Dash, Streamlit, and Bokeh apps). Much like deploying R +those based on Flask or FastAPI) and apps (such as Dash, Streamlit, and Bokeh apps). +Much like deploying R content to RStudio Connect, there are some caveats to understand when replicating your environment on the RStudio Connect server: @@ -262,9 +263,17 @@ rsconnect write-manifest notebook my-notebook.ipynb ### API/Application Deployment Options -There are a variety of options available to you when deploying a Python WSGI-style API, -Dash, Streamlit, or Bokeh application. All options below apply equally to `api`, -`dash`, `streamlit`, and `bokeh` sub-commands. +You can deploy a variety of APIs and applications using sub-commands of the +`rsconnect deploy` command. + +* `api`: WSGI-compliant APIs such as Flask and packages based on Flask +* `fastapi`: ASGI-compliant APIs (FastAPI, Quart, Sanic, and Falcon) +* `dash`: Python Dash apps +* `streamlit`: Streamlit apps +* `bokeh`: Bokeh server apps + +All options below apply equally to the `api`, `fastapi`, `dash`, `streamlit`, +and `bokeh` sub-commands. #### Including Extra Files @@ -389,8 +398,8 @@ this, use the `--title` option: rsconnect deploy notebook --title "My Notebook" my-notebook.ipynb ``` -When using `rsconnect deploy api`, `rsconnect deploy dash`, `rsconnect deploy -streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory +When using `rsconnect deploy api`, `rsconnect deploy fastapi`, `rsconnect deploy dash`, +`rsconnect deploy streamlit`, or `rsconnect deploy bokeh`, the title is derived from the directory containing the API or application. When using `rsconnect deploy manifest`, the title is derived from the primary diff --git a/mock_connect/mock_connect/http_helpers.py b/mock_connect/mock_connect/http_helpers.py index 12b0d30e..2e9a9e13 100644 --- a/mock_connect/mock_connect/http_helpers.py +++ b/mock_connect/mock_connect/http_helpers.py @@ -43,7 +43,10 @@ def _make_json_ready(thing): def endpoint( - authenticated: bool = False, auth_optional: bool = False, cls=None, writes_json: bool = False, + authenticated: bool = False, + auth_optional: bool = False, + cls=None, + writes_json: bool = False, ): def decorator(function): @wraps(function) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 16686f6d..4a505f6f 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -504,10 +504,19 @@ def deploy_jupyter_notebook( 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, title, static + ( + 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) + python, environment = get_python_env_info( + file_name, + python, + conda_mode=conda_mode, + force_generate=force_generate, ) - python, environment = get_python_env_info(file_name, python, conda_mode=conda_mode, force_generate=force_generate,) bundle = create_notebook_deployment_bundle( file_name, extra_files, app_mode, python, environment, hide_all_input, hide_tagged_input ) @@ -526,7 +535,16 @@ 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, + app_store, + file_name, + app_id, + app_mode, + name, + title, + title_is_default, + bundle, + log_callback, ): """ A common function to finish up the deploy process once all the data (bundle @@ -551,7 +569,13 @@ def _finalize_deploy( app = deploy_bundle(connect_server, app_id, name, title, title_is_default, bundle) app_url, log_lines = spool_deployment_log(connect_server, app, log_callback) app_store.set( - connect_server.url, abspath(file_name), app_url, app["app_id"], app["app_guid"], title, app_mode, + connect_server.url, + abspath(file_name), + app_url, + app["app_id"], + app["app_guid"], + title, + app_mode, ) return app_url, log_lines @@ -625,6 +649,62 @@ 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, +): + """ +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. + + :param connect_server: the Connect server information. + :param directory: the app directory to deploy. + :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 force_generate: force generating "requirements.txt" or "environment.yml", + even if it already exists. + :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. + :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. + """ + return _deploy_by_python_framework( + connect_server, + directory, + extra_files, + excludes, + entry_point, + gather_basic_deployment_info_for_fastapi, + new, + app_id, + title, + python, + conda_mode, + force_generate, + log_callback, + ) + + def deploy_dash_app( connect_server, directory, @@ -836,10 +916,20 @@ def _deploy_by_python_framework( """ module_file = fake_module_file_from_directory(directory) app_store = AppStore(module_file) - (entry_point, app_id, deployment_name, deployment_title, default_title, app_mode,) = gatherer( - connect_server, app_store, directory, entry_point, new, app_id, title + ( + entry_point, + app_id, + deployment_name, + deployment_title, + default_title, + app_mode, + ) = gatherer(connect_server, app_store, directory, entry_point, new, app_id, title) + _, environment = get_python_env_info( + directory, + python, + conda_mode=conda_mode, + force_generate=force_generate, ) - _, environment = get_python_env_info(directory, python, conda_mode=conda_mode, force_generate=force_generate,) bundle = create_api_deployment_bundle(directory, extra_files, excludes, entry_point, app_mode, environment) return _finalize_deploy( connect_server, @@ -856,7 +946,12 @@ 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, + manifest_file_name, + new=False, + app_id=None, + title=None, + log_callback=None, ): """ A function to deploy a Jupyter notebook to Connect. Depending on the files involved @@ -1003,13 +1098,21 @@ def _generate_gather_basic_deployment_info_for_python(app_mode): def gatherer(connect_server, app_store, directory, entry_point, new, app_id, title): return _gather_basic_deployment_info_for_framework( - connect_server, app_store, directory, entry_point, new, app_id, app_mode, title, + connect_server, + app_store, + directory, + entry_point, + new, + app_id, + app_mode, + title, ) return gatherer gather_basic_deployment_info_for_api = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_API) +gather_basic_deployment_info_for_fastapi = _generate_gather_basic_deployment_info_for_python(AppModes.PYTHON_FASTAPI) gather_basic_deployment_info_for_dash = _generate_gather_basic_deployment_info_for_python(AppModes.DASH_APP) gather_basic_deployment_info_for_streamlit = _generate_gather_basic_deployment_info_for_python(AppModes.STREAMLIT_APP) gather_basic_deployment_info_for_bokeh = _generate_gather_basic_deployment_info_for_python(AppModes.BOKEH_APP) @@ -1102,7 +1205,12 @@ 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, + file_name, + extra_files, + app_mode, + python, + environment, + extra_files_need_validating=True, hide_all_input=None, hide_tagged_input=None, ): @@ -1116,7 +1224,7 @@ def create_notebook_deployment_bundle( :param environment: environmental information. :param extra_files_need_validating: a flag indicating whether the list of extra :param hide_all_input: if True, will hide all input cells when rendering output - :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output files should be validated or not. Part of validating includes qualifying each with the parent directory of the notebook file. If you provide False here, make sure the names are properly qualified first. @@ -1139,7 +1247,13 @@ def create_notebook_deployment_bundle( def create_api_deployment_bundle( - directory, extra_files, excludes, entry_point, app_mode, environment, extra_files_need_validating=True, + directory, + extra_files, + excludes, + entry_point, + app_mode, + environment, + extra_files_need_validating=True, ): """ Create an in-memory bundle, ready to deploy. @@ -1223,17 +1337,21 @@ def create_notebook_manifest_and_environment_file( :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 + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output :return: """ if ( - not write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input) + not write_notebook_manifest_json( + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input + ) or force ): write_environment_file(environment, dirname(entry_point_file)) -def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input): +def write_notebook_manifest_json( + entry_point_file, environment, app_mode, extra_files, hide_all_input, hide_tagged_input +): """ Creates and writes a manifest.json file for the given entry point file. If the application mode is not provided, an attempt will be made to resolve one @@ -1247,7 +1365,7 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_ 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 + :param hide_tagged_input: If True, will hide input code cells with the 'hide_input' tag when rendering output :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ @@ -1276,7 +1394,13 @@ def write_notebook_manifest_json(entry_point_file, environment, app_mode, extra_ def create_api_manifest_and_environment_file( - directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None, force=True, + directory, + entry_point, + environment, + app_mode=AppModes.PYTHON_API, + extra_files=None, + excludes=None, + force=True, ): """ Creates and writes a manifest.json file for the given Python API entry point. If @@ -1299,7 +1423,12 @@ def create_api_manifest_and_environment_file( def write_api_manifest_json( - directory, entry_point, environment, app_mode=AppModes.PYTHON_API, extra_files=None, excludes=None, + directory, + entry_point, + environment, + app_mode=AppModes.PYTHON_API, + extra_files=None, + excludes=None, ): """ Creates and writes a manifest.json file for the given entry point file. If diff --git a/rsconnect/api.py b/rsconnect/api.py index 00afb7e8..4a0d9293 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -62,7 +62,11 @@ def __init__(self, server, cookies=None, timeout=30): if cookies is None: cookies = server.cookie_jar super(RSConnect, self).__init__( - append_to_path(server.url, "__api__"), server.insecure, server.ca_data, cookies, timeout, + append_to_path(server.url, "__api__"), + server.insecure, + server.ca_data, + cookies, + timeout, ) self._server = server @@ -100,7 +104,10 @@ def app_deploy(self, app_id, bundle_id=None): return self.post("applications/%s/deploy" % app_id, body={"bundle": bundle_id}) def app_publish(self, app_id, access): - return self.post("applications/%s" % app_id, body={"access_type": access, "id": app_id, "needs_config": False},) + return self.post( + "applications/%s" % app_id, + body={"access_type": access, "id": app_id, "needs_config": False}, + ) def app_config(self, app_id): return self.get("applications/%s/config" % app_id) @@ -458,7 +465,9 @@ def find_unique_name(connect_server, name): :return: the name, potentially with a suffixed number to guarantee uniqueness. """ existing_names = retrieve_matching_apps( - connect_server, filters={"search": name}, mapping_function=lambda client, app: app["name"], + connect_server, + filters={"search": name}, + mapping_function=lambda client, app: app["name"], ) if name in existing_names: diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 81606e9c..be834311 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -150,11 +150,13 @@ def write_manifest(relative_dir, nb_name, environment, output_dir, hide_all_inpu manifest_filename = "manifest.json" manifest = make_source_manifest(nb_name, environment, AppModes.JUPYTER_NOTEBOOK) if hide_all_input: - if 'jupyter' not in manifest: manifest['jupyter']= {} - manifest['jupyter'].update({'hide_all_input': hide_all_input}) + if "jupyter" not in manifest: + manifest["jupyter"] = {} + manifest["jupyter"].update({"hide_all_input": hide_all_input}) if hide_tagged_input: - if 'jupyter' not in manifest: manifest['jupyter']= {} - manifest['jupyter'].update({'hide_tagged_input': hide_tagged_input}) + if "jupyter" not in manifest: + manifest["jupyter"] = {} + manifest["jupyter"].update({"hide_tagged_input": hide_tagged_input}) manifest_file = join(output_dir, manifest_filename) created = [] skipped = [] @@ -227,11 +229,13 @@ def make_notebook_source_bundle( manifest = make_source_manifest(nb_name, environment, AppModes.JUPYTER_NOTEBOOK) if hide_all_input: - if 'jupyter' not in manifest: manifest['jupyter']= {} - manifest['jupyter'].update({'hide_all_input': hide_all_input}) + if "jupyter" not in manifest: + manifest["jupyter"] = {} + manifest["jupyter"].update({"hide_all_input": hide_all_input}) if hide_tagged_input: - if 'jupyter' not in manifest: manifest['jupyter']= {} - manifest['jupyter'].update({'hide_tagged_input': hide_tagged_input}) + if "jupyter" not in manifest: + manifest["jupyter"] = {} + manifest["jupyter"].update({"hide_tagged_input": hide_tagged_input}) manifest_add_file(manifest, nb_name, base_dir) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -292,11 +296,11 @@ def make_notebook_html_bundle( filename, ] if hide_all_input and hide_tagged_input or hide_all_input: - cmd.append('--no-input') + cmd.append("--no-input") elif hide_tagged_input: - version = check_output([python, '--version']).decode("utf-8") - if version >= 'Python 3': - cmd.append('--TagRemovePreprocessor.remove_input_tags=hide_input') + version = check_output([python, "--version"]).decode("utf-8") + if version >= "Python 3": + cmd.append("--TagRemovePreprocessor.remove_input_tags=hide_input") else: cmd.append("--TagRemovePreprocessor.remove_input_tags=['hide_input']") try: diff --git a/rsconnect/main.py b/rsconnect/main.py index 68c43b63..d9cbba89 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -16,6 +16,7 @@ deploy_bundle, describe_manifest, gather_basic_deployment_info_for_api, + gather_basic_deployment_info_for_fastapi, gather_basic_deployment_info_for_dash, gather_basic_deployment_info_for_streamlit, gather_basic_deployment_info_for_bokeh, @@ -113,7 +114,10 @@ def _test_server_and_api(server, api_key, insecure, ca_cert): @click.option("--name", "-n", required=True, help="The nickname to associate with the server.") @click.option("--server", "-s", required=True, help="The URL for the RStudio Connect server.") @click.option( - "--api-key", "-k", required=True, help="The API key to use to authenticate with RStudio Connect.", + "--api-key", + "-k", + required=True, + help="The API key to use to authenticate with RStudio Connect.", ) @click.option("--insecure", "-i", is_flag=True, help="Disable TLS certification/host validation.") @click.option("--cacert", "-c", type=click.File(), help="The path to trusted TLS CA certificates.") @@ -127,7 +131,11 @@ def add(name, server, api_key, insecure, cacert, verbose): real_server, _ = _test_server_and_api(server, api_key, insecure, cacert) server_store.set( - name, real_server.url, real_server.api_key, real_server.insecure, real_server.ca_data, + name, + real_server.url, + real_server.api_key, + real_server.insecure, + real_server.ca_data, ) if old_server: @@ -175,16 +183,28 @@ def list_servers(verbose): ), ) @click.option( - "--name", "-n", help="The nickname of the RStudio Connect server to get details for.", + "--name", + "-n", + help="The nickname of the RStudio Connect server to get details for.", ) @click.option( - "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to get details for.", + "--server", + "-s", + envvar="CONNECT_SERVER", + help="The URL for the RStudio Connect server to get details for.", ) @click.option( - "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", + "--api-key", + "-k", + envvar="CONNECT_API_KEY", + help="The API key to use to authenticate with RStudio Connect.", ) @click.option( - "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", + "--insecure", + "-i", + envvar="CONNECT_INSECURE", + is_flag=True, + help="Disable TLS certification/host validation.", ) @click.option( "--cacert", @@ -442,12 +462,21 @@ def _warn_on_ignored_requirements(directory, requirements_file_name): """ if exists(join(directory, requirements_file_name)): click.secho( - " Warning: the existing %s file will not be used or considered." % requirements_file_name, fg="yellow", + " Warning: the existing %s file will not be used or considered." % requirements_file_name, + fg="yellow", ) def _deploy_bundle( - connect_server, app_store, primary_path, app_id, app_mode, name, title, title_is_default, bundle, + connect_server, + app_store, + primary_path, + app_id, + app_mode, + name, + title, + title_is_default, + bundle, ): """ Does the work of uploading a prepared bundle. @@ -467,7 +496,13 @@ def _deploy_bundle( with cli_feedback("Saving deployment data"): app_store.set( - connect_server.url, abspath(primary_path), app["app_url"], app["app_id"], app["app_guid"], title, app_mode, + connect_server.url, + abspath(primary_path), + app["app_url"], + app["app_id"], + app["app_guid"], + title, + app_mode, ) with cli_feedback(""): @@ -482,7 +517,13 @@ def _deploy_bundle( # save the config URL, replacing the old app URL we got during deployment # (which is the Open Solo URL). app_store.set( - connect_server.url, abspath(primary_path), app_url, app["app_id"], app["app_guid"], app["title"], app_mode, + connect_server.url, + abspath(primary_path), + app_url, + app["app_id"], + app["app_guid"], + app["title"], + app_mode, ) @@ -498,13 +539,23 @@ def _deploy_bundle( ) @click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") @click.option( - "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", + "--server", + "-s", + envvar="CONNECT_SERVER", + help="The URL for the RStudio Connect server to deploy to.", ) @click.option( - "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", + "--api-key", + "-k", + envvar="CONNECT_API_KEY", + help="The API key to use to authenticate with RStudio Connect.", ) @click.option( - "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", + "--insecure", + "-i", + envvar="CONNECT_INSECURE", + is_flag=True, + help="Disable TLS certification/host validation.", ) @click.option( "--cacert", @@ -533,7 +584,9 @@ def _deploy_bundle( ), ) @click.option( - "--app-id", "-a", help="Existing app ID or GUID to replace. Cannot be used with --new.", + "--app-id", + "-a", + help="Existing app ID or GUID to replace. Cannot be used with --new.", ) @click.option("--title", "-t", help="Title of the content (default is the same as the filename).") @click.option( @@ -546,17 +599,28 @@ def _deploy_bundle( ), ) @click.option( - "--conda", "-C", is_flag=True, hidden=True, help="Use Conda to deploy (requires Connect version 1.8.2 or later)", + "--conda", + "-C", + is_flag=True, + hidden=True, + help="Use Conda to deploy (requires RStudio Connect version 1.8.2 or later)", ) @click.option( - "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") @click.option("--hide-all-input", is_flag=True, default=False, help="Hide all input cells when rendering output") -@click.option("--hide-tagged-input", is_flag=True, default=False, help="Hide input code cells with the 'hide_input' tag") +@click.option( + "--hide-tagged-input", is_flag=True, default=False, help="Hide input code cells with the 'hide_input' tag" +) @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( - "extra_files", nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), ) def deploy_notebook( name, @@ -583,9 +647,13 @@ 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, title, static - ) + ( + 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) click.secho(' Deploying %s to server "%s"' % (file, connect_server.url)) @@ -611,7 +679,15 @@ def deploy_notebook( file, extra_files, app_mode, python, environment, False, hide_all_input, hide_tagged_input ) _deploy_bundle( - connect_server, app_store, file, app_id, app_mode, deployment_name, title, default_title, bundle, + connect_server, + app_store, + file, + app_id, + app_mode, + deployment_name, + title, + default_title, + bundle, ) @@ -627,13 +703,23 @@ def deploy_notebook( ) @click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") @click.option( - "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", + "--server", + "-s", + envvar="CONNECT_SERVER", + help="The URL for the RStudio Connect server to deploy to.", ) @click.option( - "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", + "--api-key", + "-k", + envvar="CONNECT_API_KEY", + help="The API key to use to authenticate with RStudio Connect.", ) @click.option( - "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", + "--insecure", + "-i", + envvar="CONNECT_INSECURE", + is_flag=True, + help="Disable TLS certification/host validation.", ) @click.option( "--cacert", @@ -652,7 +738,9 @@ def deploy_notebook( ), ) @click.option( - "--app-id", "-a", help="Existing app ID or GUID to replace. Cannot be used with --new.", + "--app-id", + "-a", + help="Existing app ID or GUID to replace. Cannot be used with --new.", ) @click.option("--title", "-t", help="Title of the content (default is the same as the filename).") @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") @@ -684,7 +772,10 @@ def deploy_manifest(name, server, api_key, insecure, cacert, new, app_id, title, try: bundle = make_manifest_bundle(file) except IOError as error: - msg = "Unable to include the file %s in the bundle: %s" % (error.filename, error.args[1],) + msg = "Unable to include the file %s in the bundle: %s" % ( + error.filename, + error.args[1], + ) if error.args[0] == errno.ENOENT: msg = "\n".join( [ @@ -698,7 +789,15 @@ def deploy_manifest(name, server, api_key, insecure, cacert, new, app_id, title, raise api.RSConnectException(msg) _deploy_bundle( - connect_server, app_store, file, app_id, app_mode, deployment_name, title, default_title, bundle, + connect_server, + app_store, + file, + app_id, + app_mode, + deployment_name, + title, + default_title, + bundle, ) @@ -716,13 +815,23 @@ def generate_deploy_python(app_mode, alias, min_version): ) @click.option("--name", "-n", help="The nickname of the RStudio Connect server to deploy to.") @click.option( - "--server", "-s", envvar="CONNECT_SERVER", help="The URL for the RStudio Connect server to deploy to.", + "--server", + "-s", + envvar="CONNECT_SERVER", + help="The URL for the RStudio Connect server to deploy to.", ) @click.option( - "--api-key", "-k", envvar="CONNECT_API_KEY", help="The API key to use to authenticate with RStudio Connect.", + "--api-key", + "-k", + envvar="CONNECT_API_KEY", + help="The API key to use to authenticate with RStudio Connect.", ) @click.option( - "--insecure", "-i", envvar="CONNECT_INSECURE", is_flag=True, help="Disable TLS certification/host validation.", + "--insecure", + "-i", + envvar="CONNECT_INSECURE", + is_flag=True, + help="Disable TLS certification/host validation.", ) @click.option( "--cacert", @@ -758,10 +867,14 @@ def generate_deploy_python(app_mode, alias, min_version): ), ) @click.option( - "--app-id", "-a", help="Existing app ID or GUID to replace. Cannot be used with --new.", + "--app-id", + "-a", + help="Existing app ID or GUID to replace. Cannot be used with --new.", ) @click.option( - "--title", "-t", help="Title of the content (default is the same as the directory).", + "--title", + "-t", + help="Title of the content (default is the same as the directory).", ) @click.option( "--python", @@ -780,12 +893,17 @@ def generate_deploy_python(app_mode, alias, min_version): help="Use Conda to deploy (requires Connect version 1.8.2 or later)", ) @click.option( - "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', ) @click.option("--verbose", "-v", is_flag=True, help="Print detailed messages.") @click.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), + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), ) def deploy_app( name, @@ -824,6 +942,7 @@ def deploy_app( extra_files, { AppModes.PYTHON_API: gather_basic_deployment_info_for_api, + AppModes.PYTHON_FASTAPI: gather_basic_deployment_info_for_fastapi, AppModes.DASH_APP: gather_basic_deployment_info_for_dash, AppModes.STREAMLIT_APP: gather_basic_deployment_info_for_streamlit, AppModes.BOKEH_APP: gather_basic_deployment_info_for_bokeh, @@ -834,6 +953,8 @@ def deploy_app( deploy_api = generate_deploy_python(app_mode=AppModes.PYTHON_API, alias="api", min_version="1.8.2") +# TODO: set fastapi min_version correctly +deploy_fastapi = generate_deploy_python(app_mode=AppModes.PYTHON_FASTAPI, alias="fastapi", min_version="2021.08.0") deploy_dash_app = generate_deploy_python(app_mode=AppModes.DASH_APP, alias="dash", min_version="1.8.2") deploy_streamlit_app = generate_deploy_python(app_mode=AppModes.STREAMLIT_APP, alias="streamlit", min_version="1.8.4") deploy_bokeh_app = generate_deploy_python(app_mode=AppModes.BOKEH_APP, alias="bokeh", min_version="1.8.4") @@ -917,7 +1038,15 @@ def _deploy_by_framework( bundle = create_api_deployment_bundle(directory, extra_files, exclude, entrypoint, app_mode, environment, False) _deploy_bundle( - connect_server, app_store, directory, app_id, app_mode, deployment_name, title, default_title, bundle, + connect_server, + app_store, + directory, + app_id, + app_mode, + deployment_name, + title, + default_title, + bundle, ) @@ -971,17 +1100,26 @@ def write_manifest(): + "The Python environment must have the rsconnect package installed.", ) @click.option( - "--conda", "-C", is_flag=True, hidden=True, help="Use Conda to deploy (requires Connect version 1.8.2 or later)", + "--conda", + "-C", + is_flag=True, + hidden=True, +help="Use Conda to deploy (requires RStudio Connect version 1.8.2 or later)", ) @click.option( - "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', ) @click.option("--hide-all-input", help="Hide all input cells when rendering output") @click.option("--hide-tagged-input", is_flag=True, default=None, help="Hide input code cells with the 'hide_input' tag") @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.argument("file", type=click.Path(exists=True, dir_okay=False, file_okay=True)) @click.argument( - "extra_files", nargs=-1, type=click.Path(exists=True, dir_okay=False, file_okay=True), + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), ) def write_manifest_notebook( overwrite, python, conda, force_generate, verbose, file, extra_files, hide_all_input=None, hide_tagged_input=None @@ -1014,7 +1152,8 @@ def write_manifest_notebook( if environment_file_exists and not force_generate: click.secho( - " Warning: %s already exists and will not be overwritten." % environment.filename, fg="yellow", + " Warning: %s already exists and will not be overwritten." % environment.filename, + fg="yellow", ) else: with cli_feedback("Creating %s" % environment.filename): @@ -1065,24 +1204,47 @@ def generate_write_manifest_python(app_mode, alias): help="Use Conda to deploy (requires Connect version 1.8.2 or later)", ) @click.option( - "--force-generate", "-g", is_flag=True, help='Force generating "requirements.txt", even if it already exists.', + "--force-generate", + "-g", + is_flag=True, + help='Force generating "requirements.txt", even if it already exists.', ) @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.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), + "extra_files", + nargs=-1, + type=click.Path(exists=True, dir_okay=False, file_okay=True), ) def manifest_writer( - overwrite, entrypoint, exclude, python, conda, force_generate, verbose, directory, extra_files, + overwrite, + entrypoint, + exclude, + python, + conda, + force_generate, + verbose, + directory, + extra_files, ): _write_framework_manifest( - overwrite, entrypoint, exclude, python, conda, force_generate, verbose, directory, extra_files, app_mode, + overwrite, + entrypoint, + exclude, + python, + conda, + force_generate, + verbose, + directory, + extra_files, + app_mode, ) return manifest_writer write_manifest_api = generate_write_manifest_python(AppModes.PYTHON_API, alias="api") +write_manifest_fastapi = generate_write_manifest_python(AppModes.PYTHON_FASTAPI, alias="fastapi") write_manifest_dash = generate_write_manifest_python(AppModes.DASH_APP, alias="dash") write_manifest_streamlit = generate_write_manifest_python(AppModes.STREAMLIT_APP, alias="streamlit") write_manifest_bokeh = generate_write_manifest_python(AppModes.BOKEH_APP, alias="bokeh") @@ -1090,7 +1252,16 @@ def manifest_writer( # noinspection SpellCheckingInspection def _write_framework_manifest( - overwrite, entrypoint, exclude, python, conda, force_generate, verbose, directory, extra_files, app_mode, + overwrite, + entrypoint, + exclude, + python, + conda, + force_generate, + verbose, + directory, + extra_files, + app_mode, ): """ A common function for writing manifests for APIs as well as Dash, Streamlit, and Bokeh apps. @@ -1130,7 +1301,8 @@ def _write_framework_manifest( if environment_file_exists and not force_generate: click.secho( - " Warning: %s already exists and will not be overwritten." % environment.filename, fg="yellow", + " Warning: %s already exists and will not be overwritten." % environment.filename, + fg="yellow", ) else: with cli_feedback("Creating %s" % environment.filename): diff --git a/rsconnect/metadata.py b/rsconnect/metadata.py index 62dc668d..5eee25c8 100644 --- a/rsconnect/metadata.py +++ b/rsconnect/metadata.py @@ -253,7 +253,14 @@ def set(self, name, url, api_key, insecure=False, ca_data=None): :param ca_data: client side certificate data to use for TLS. """ self._set( - name, dict(name=name, url=url, api_key=api_key, insecure=insecure, ca_cert=ca_data,), + name, + dict( + name=name, + url=url, + api_key=api_key, + insecure=insecure, + ca_cert=ca_data, + ), ) def remove_by_name(self, name): diff --git a/rsconnect/models.py b/rsconnect/models.py index 0f842632..dbd50f01 100644 --- a/rsconnect/models.py +++ b/rsconnect/models.py @@ -57,6 +57,7 @@ class AppModes(object): DASH_APP = AppMode(9, "python-dash", "Dash Application") STREAMLIT_APP = AppMode(10, "python-streamlit", "Streamlit Application") BOKEH_APP = AppMode(11, "python-bokeh", "Bokeh Application") + PYTHON_FASTAPI = AppMode(12, "python-fastapi", "Python FastAPI") _modes = [ UNKNOWN, @@ -71,12 +72,17 @@ class AppModes(object): DASH_APP, STREAMLIT_APP, BOKEH_APP, + PYTHON_FASTAPI, ] @classmethod def get_by_ordinal(cls, ordinal, return_unknown=False): """Get an AppMode by its associated ordinal (integer)""" - return cls._find_by(lambda mode: mode.ordinal() == ordinal, "with ordinal %s" % ordinal, return_unknown,) + return cls._find_by( + lambda mode: mode.ordinal() == ordinal, + "with ordinal %s" % ordinal, + return_unknown, + ) @classmethod def get_by_name(cls, name, return_unknown=False): @@ -93,7 +99,9 @@ def get_by_extension(cls, extension, return_unknown=False): raise ValueError("No app mode with extension %s" % extension) return cls._find_by( - lambda mode: mode.extension() == extension, "with extension: %s" % extension, return_unknown, + lambda mode: mode.extension() == extension, + "with extension: %s" % extension, + return_unknown, ) @classmethod diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 8ae76c48..14e27111 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -49,7 +49,14 @@ def test_source_bundle1(self): ) as tar: names = sorted(tar.getnames()) - self.assertEqual(names, ["dummy.ipynb", "manifest.json", "requirements.txt",]) + self.assertEqual( + names, + [ + "dummy.ipynb", + "manifest.json", + "requirements.txt", + ], + ) reqs = tar.extractfile("requirements.txt").read() self.assertEqual(reqs, b"numpy\npandas\nmatplotlib\n") @@ -70,13 +77,21 @@ def test_source_bundle1(self): manifest, { u"version": 1, - u"metadata": {u"appmode": u"jupyter-static", u"entrypoint": u"dummy.ipynb",}, + u"metadata": { + u"appmode": u"jupyter-static", + u"entrypoint": u"dummy.ipynb", + }, u"python": { u"version": self.python_version(), - u"package_manager": {u"name": u"pip", u"package_file": u"requirements.txt",}, + u"package_manager": { + u"name": u"pip", + u"package_file": u"requirements.txt", + }, }, u"files": { - u"dummy.ipynb": {u"checksum": ipynb_hash,}, + u"dummy.ipynb": { + u"checksum": ipynb_hash, + }, u"requirements.txt": {u"checksum": u"5f2a5e862fe7afe3def4a57bb5cfb214"}, }, }, @@ -98,7 +113,15 @@ def test_source_bundle2(self): ) as tar: names = sorted(tar.getnames()) - self.assertEqual(names, ["data.csv", "dummy.ipynb", "manifest.json", "requirements.txt",]) + self.assertEqual( + names, + [ + "data.csv", + "dummy.ipynb", + "manifest.json", + "requirements.txt", + ], + ) reqs = tar.extractfile("requirements.txt").read() @@ -124,13 +147,21 @@ def test_source_bundle2(self): manifest, { u"version": 1, - u"metadata": {u"appmode": u"jupyter-static", u"entrypoint": u"dummy.ipynb",}, + u"metadata": { + u"appmode": u"jupyter-static", + u"entrypoint": u"dummy.ipynb", + }, u"python": { u"version": self.python_version(), - u"package_manager": {u"name": u"pip", u"package_file": u"requirements.txt",}, + u"package_manager": { + u"name": u"pip", + u"package_file": u"requirements.txt", + }, }, u"files": { - u"dummy.ipynb": {u"checksum": ipynb_hash,}, + u"dummy.ipynb": { + u"checksum": ipynb_hash, + }, u"data.csv": {u"checksum": u"f2bd77cc2752b3efbb732b761d2aa3c3"}, }, }, @@ -187,13 +218,26 @@ def do_test_html_bundle(self, directory): try: names = sorted(tar.getnames()) - self.assertEqual(names, ["dummy.html", "manifest.json",]) + self.assertEqual( + names, + [ + "dummy.html", + "manifest.json", + ], + ) manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) # noinspection SpellCheckingInspection self.assertEqual( - manifest, {u"version": 1, u"metadata": {u"appmode": u"static", u"primary_html": u"dummy.html",},}, + manifest, + { + u"version": 1, + u"metadata": { + u"appmode": u"static", + u"primary_html": u"dummy.html", + }, + }, ) finally: tar.close() diff --git a/tests/test_http_support.py b/tests/test_http_support.py index 677fc875..ea4df8b2 100644 --- a/tests/test_http_support.py +++ b/tests/test_http_support.py @@ -64,7 +64,8 @@ def test_basic_stuff(self): jar = CookieJar() jar.store_cookies(FakeSetCookieResponse(["my-cookie=my-value", "my-2nd-cookie=my-other-value"])) self.assertEqual( - jar.get_cookie_header_value(), "my-cookie=my-value; my-2nd-cookie=my-other-value", + jar.get_cookie_header_value(), + "my-cookie=my-value; my-2nd-cookie=my-other-value", ) def test_from_dict(self): diff --git a/tests/test_metadata.py b/tests/test_metadata.py index b8569c98..36b2d32e 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -26,7 +26,13 @@ def test_add(self): self.assertEqual( self.server_store.get_by_name("bar"), - dict(name="bar", url="http://connect.remote", api_key="differentApiKey", insecure=True, ca_cert=None,), + dict( + name="bar", + url="http://connect.remote", + api_key="differentApiKey", + insecure=True, + ca_cert=None, + ), ) def test_remove_by_name(self): @@ -138,7 +144,13 @@ def setUp(self): self.app_store = AppStore(self.nb_path) self.app_store.set( - "http://dev", "/path/to/file", "http://dev/apps/123", 123, "shouldBeAGuid", "Important Title", "static", + "http://dev", + "/path/to/file", + "http://dev/apps/123", + 123, + "shouldBeAGuid", + "Important Title", + "static", ) self.app_store.set( "http://prod", diff --git a/tests/test_models.py b/tests/test_models.py index 4d37a6e6..1b23b9c6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -29,8 +29,7 @@ def test_app_modes_constants(self): descriptions = [] extensions = [] - self.assertEqual(len(defined), 12) - self.assertEqual(len(modes), 12) + self.assertEqual(len(modes), len(defined)) # This makes sure all named mode constants appear in the modes list. for name in defined: