From e2c68e2e5c244e512536f802f448566fc35f0af3 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 17 Feb 2026 15:43:33 -0500 Subject: [PATCH 1/3] feat: auto-detect Shiny Quarto documents in write-manifest and deploy Add support for automatically detecting `server: shiny` in Quarto documents by inspecting the `quarto inspect` output. When detected, the commands now use `quarto-shiny` app mode instead of always using `quarto-static`. This allows `rsconnect write-manifest quarto` and `rsconnect deploy quarto` to correctly handle Shiny Quarto documents without manual intervention. Fixes #754 Co-Authored-By: Claude Opus 4.5 --- rsconnect/actions.py | 51 +++++++++++++++++++++++++++ rsconnect/main.py | 9 +++-- tests/test_actions.py | 81 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index a16e7359..0f5d79c1 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -276,6 +276,57 @@ def validate_quarto_engines(inspect: QuartoInspectResult): return engines +def is_quarto_shiny(inspect: QuartoInspectResult) -> bool: + """ + Determines if the Quarto document uses Shiny by checking the quarto inspect output. + + Quarto documents with `server: shiny` in their YAML front matter will have + this reflected in the inspect output in multiple locations: + - formats..metadata.server.type == "shiny" + - fileInformation..metadata.server == "shiny" + + :param inspect: The parsed JSON from a 'quarto inspect' against the project. + :return: True if the document uses Shiny, False otherwise. + """ + # Cast to Any for accessing fields not defined in the TypedDict. + # The quarto inspect output contains many more fields than we've typed. + inspect_any = cast(typing.Any, inspect) + + # Check formats..metadata.server.type + formats = inspect_any.get("formats", {}) + for format_data in formats.values(): + if isinstance(format_data, dict): + metadata = format_data.get("metadata", {}) + if isinstance(metadata, dict): + server = metadata.get("server", {}) + if isinstance(server, dict) and server.get("type") == "shiny": + return True + + # Check fileInformation..metadata.server + file_info = inspect_any.get("fileInformation", {}) + for file_data in file_info.values(): + if isinstance(file_data, dict): + metadata = file_data.get("metadata", {}) + if isinstance(metadata, dict): + server = metadata.get("server") + if server == "shiny": + return True + + return False + + +def infer_quarto_app_mode(inspect: QuartoInspectResult) -> AppMode: + """ + Infers the appropriate app mode for a Quarto document based on the inspect output. + + :param inspect: The parsed JSON from a 'quarto inspect' against the project. + :return: AppModes.SHINY_QUARTO if the document uses Shiny, AppModes.STATIC_QUARTO otherwise. + """ + if is_quarto_shiny(inspect): + return AppModes.SHINY_QUARTO + return AppModes.STATIC_QUARTO + + # =============================================================================== # START: The following deprecated functions are here only for the vetiver-python # package. diff --git a/rsconnect/main.py b/rsconnect/main.py index e939ab30..4a8df359 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -42,6 +42,7 @@ cli_feedback, create_quarto_deployment_bundle, describe_manifest, + infer_quarto_app_mode, quarto_inspect, set_verbosity, test_api_key, @@ -1598,6 +1599,7 @@ def deploy_quarto( logger.debug("Quarto: %s" % quarto) inspect = quarto_inspect(quarto, file_or_directory) engines = validate_quarto_engines(inspect) + app_mode = infer_quarto_app_mode(inspect) environment = None if "jupyter" in engines: @@ -1635,13 +1637,13 @@ def deploy_quarto( ( ce.validate_server() - .validate_app_mode(app_mode=AppModes.STATIC_QUARTO) + .validate_app_mode(app_mode=app_mode) .make_bundle( create_quarto_deployment_bundle, file_or_directory, extra_files, exclude, - AppModes.STATIC_QUARTO, + app_mode, inspect, environment, image=image, @@ -2542,11 +2544,12 @@ def write_manifest_quarto( with cli_feedback("Creating %s" % environment.filename): write_environment_file(environment, base_dir) + app_mode = infer_quarto_app_mode(inspect) with cli_feedback("Creating manifest.json"): write_quarto_manifest_json( file_or_directory, inspect, - AppModes.STATIC_QUARTO, + app_mode, environment, extra_files, exclude, diff --git a/tests/test_actions.py b/tests/test_actions.py index aca947cf..575e7f91 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,9 +1,88 @@ import os from unittest import TestCase -from rsconnect.actions import _verify_server +from rsconnect.actions import _verify_server, infer_quarto_app_mode, is_quarto_shiny from rsconnect.api import RSConnectServer from rsconnect.exception import RSConnectException +from rsconnect.models import AppModes + + +class TestQuartoShinyDetection(TestCase): + def test_is_quarto_shiny_via_formats_metadata(self): + """Test detection via formats..metadata.server.type""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "formats": { + "html": { + "metadata": { + "server": {"type": "shiny"}, + }, + }, + }, + } + self.assertTrue(is_quarto_shiny(inspect)) + + def test_is_quarto_shiny_via_file_information(self): + """Test detection via fileInformation..metadata.server""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "fileInformation": { + "/path/to/app.qmd": { + "metadata": { + "server": "shiny", + }, + }, + }, + } + self.assertTrue(is_quarto_shiny(inspect)) + + def test_is_quarto_shiny_static_document(self): + """Test that static documents are not detected as Shiny""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "formats": { + "html": { + "metadata": { + "title": "My Document", + }, + }, + }, + } + self.assertFalse(is_quarto_shiny(inspect)) + + def test_is_quarto_shiny_empty_inspect(self): + """Test with minimal inspect output""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["markdown"], + } + self.assertFalse(is_quarto_shiny(inspect)) + + def test_infer_quarto_app_mode_shiny(self): + """Test that Shiny documents get SHINY_QUARTO mode""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "formats": { + "html": { + "metadata": { + "server": {"type": "shiny"}, + }, + }, + }, + } + self.assertEqual(infer_quarto_app_mode(inspect), AppModes.SHINY_QUARTO) + + def test_infer_quarto_app_mode_static(self): + """Test that static documents get STATIC_QUARTO mode""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["markdown"], + } + self.assertEqual(infer_quarto_app_mode(inspect), AppModes.STATIC_QUARTO) class TestActions(TestCase): From 2ef3b2727b1b782abe7b64124092ad3285181a22 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 17 Feb 2026 15:51:16 -0500 Subject: [PATCH 2/3] review: add defensive isinstance checks for formats and fileInformation Add isinstance(dict) checks before calling .values() on formats and fileInformation to prevent potential AttributeError if Quarto inspect returns unexpected data types. This matches the defensive pattern already used for nested fields in the same function. --- rsconnect/actions.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 0f5d79c1..fb117a28 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -294,23 +294,25 @@ def is_quarto_shiny(inspect: QuartoInspectResult) -> bool: # Check formats..metadata.server.type formats = inspect_any.get("formats", {}) - for format_data in formats.values(): - if isinstance(format_data, dict): - metadata = format_data.get("metadata", {}) - if isinstance(metadata, dict): - server = metadata.get("server", {}) - if isinstance(server, dict) and server.get("type") == "shiny": - return True + if isinstance(formats, dict): + for format_data in formats.values(): + if isinstance(format_data, dict): + metadata = format_data.get("metadata", {}) + if isinstance(metadata, dict): + server = metadata.get("server", {}) + if isinstance(server, dict) and server.get("type") == "shiny": + return True # Check fileInformation..metadata.server file_info = inspect_any.get("fileInformation", {}) - for file_data in file_info.values(): - if isinstance(file_data, dict): - metadata = file_data.get("metadata", {}) - if isinstance(metadata, dict): - server = metadata.get("server") - if server == "shiny": - return True + if isinstance(file_info, dict): + for file_data in file_info.values(): + if isinstance(file_data, dict): + metadata = file_data.get("metadata", {}) + if isinstance(metadata, dict): + server = metadata.get("server") + if server == "shiny": + return True return False From 6b21dffb6df70a83686ee501546229bc7319d2f5 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Tue, 17 Feb 2026 15:53:50 -0500 Subject: [PATCH 3/3] review: add edge case tests for Quarto Shiny detection Add test cases for scenarios where the server field exists but has incorrect values (not "shiny"). This improves test coverage and makes the behavior more explicit for edge cases. Co-Authored-By: Claude Opus 4.5 --- tests/test_actions.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 575e7f91..13ad59de 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -61,6 +61,36 @@ def test_is_quarto_shiny_empty_inspect(self): } self.assertFalse(is_quarto_shiny(inspect)) + def test_is_quarto_shiny_wrong_server_type(self): + """Test that documents with wrong server type are not detected as Shiny""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "formats": { + "html": { + "metadata": { + "server": {"type": "other"}, + }, + }, + }, + } + self.assertFalse(is_quarto_shiny(inspect)) + + def test_is_quarto_shiny_wrong_server_value(self): + """Test that documents with wrong server value in fileInformation are not detected as Shiny""" + inspect = { + "quarto": {"version": "1.3.0"}, + "engines": ["jupyter"], + "fileInformation": { + "/path/to/app.qmd": { + "metadata": { + "server": "other", + }, + }, + }, + } + self.assertFalse(is_quarto_shiny(inspect)) + def test_infer_quarto_app_mode_shiny(self): """Test that Shiny documents get SHINY_QUARTO mode""" inspect = {