From ca20a1dc558ce16b63353cc5bf9abd39713148fa Mon Sep 17 00:00:00 2001 From: Anders Wiggers <47324009+anders-wiggers@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:27:23 +0200 Subject: [PATCH 1/3] Refactor extra_files handling in bundle.py refactoring of extra_files to handle building bundle for deployment which done in a sub-directory. --- rsconnect/bundle.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index fe02ae51..88a7314d 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1396,17 +1396,19 @@ def make_quarto_manifest( """ if environment: extra_files = list(extra_files or []) - + else: + extra_files = list(extra_files or []) + base_dir = file_or_directory if isdir(file_or_directory): # Directory as a Quarto project. excludes = list(excludes or []) + [".quarto"] - + project_config = quarto_inspection.get("config", {}).get("project", {}) output_dir = cast(Union[str, None], project_config.get("output-dir", None)) if output_dir: excludes = excludes + [output_dir] - + files_data = quarto_inspection.get("files", {}) files_input_data = files_data.get("input", []) # files.input is a list of absolute paths to input (rendered) @@ -1419,19 +1421,26 @@ def make_quarto_manifest( for each in files_input_data: t, _ = splitext(os.path.relpath(each, file_or_directory)) excludes = excludes + [t + ".html", t + "_files/**/*"] - + # relevant files don't need to include requirements.txt file because it is # always added to the manifest (as a buffer) from the environment contents if environment: excludes.append(environment.filename) - + relevant_files = _create_quarto_file_list(base_dir, extra_files, excludes) else: # Standalone Quarto document - base_dir = dirname(file_or_directory) - file_name = basename(file_or_directory) - relevant_files = [file_name] + list(extra_files or []) - + + # Use the common directory of the qmd and any extra files as base_dir. + # This avoids having the subfolder appear in both base_dir and rel_path. + all_files = [file_or_directory] + extra_files + + abs_paths = [os.path.abspath(p) for p in all_files] + base_dir = os.path.commonpath(abs_paths) + + # Store paths relative to base_dir + relevant_files = [os.path.relpath(p, base_dir) for p in abs_paths] + manifest = make_source_manifest( app_mode, environment, @@ -1441,13 +1450,13 @@ def make_quarto_manifest( env_management_py, env_management_r, ) - + if environment: manifest_add_buffer(manifest, environment.filename, environment.contents) - + for rel_path in relevant_files: manifest_add_file(manifest, rel_path, base_dir) - + return manifest, relevant_files From 1a5abd202a3f54057cc97b7e070a495cd7a4f463 Mon Sep 17 00:00:00 2001 From: Anders Wiggers <47324009+anders-wiggers@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:34:56 +0200 Subject: [PATCH 2/3] minor cleanup --- rsconnect/bundle.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 88a7314d..5304000b 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1396,19 +1396,15 @@ def make_quarto_manifest( """ if environment: extra_files = list(extra_files or []) - else: - extra_files = list(extra_files or []) base_dir = file_or_directory if isdir(file_or_directory): # Directory as a Quarto project. excludes = list(excludes or []) + [".quarto"] - project_config = quarto_inspection.get("config", {}).get("project", {}) output_dir = cast(Union[str, None], project_config.get("output-dir", None)) if output_dir: excludes = excludes + [output_dir] - files_data = quarto_inspection.get("files", {}) files_input_data = files_data.get("input", []) # files.input is a list of absolute paths to input (rendered) @@ -1421,16 +1417,13 @@ def make_quarto_manifest( for each in files_input_data: t, _ = splitext(os.path.relpath(each, file_or_directory)) excludes = excludes + [t + ".html", t + "_files/**/*"] - # relevant files don't need to include requirements.txt file because it is # always added to the manifest (as a buffer) from the environment contents if environment: excludes.append(environment.filename) - relevant_files = _create_quarto_file_list(base_dir, extra_files, excludes) else: # Standalone Quarto document - # Use the common directory of the qmd and any extra files as base_dir. # This avoids having the subfolder appear in both base_dir and rel_path. all_files = [file_or_directory] + extra_files @@ -1450,13 +1443,10 @@ def make_quarto_manifest( env_management_py, env_management_r, ) - if environment: - manifest_add_buffer(manifest, environment.filename, environment.contents) - + manifest_add_buffer(manifest, environment.filename, environment.contents) for rel_path in relevant_files: - manifest_add_file(manifest, rel_path, base_dir) - + manifest_add_file(manifest, rel_path, base_dir) return manifest, relevant_files From e0f9025c4cfbb29d468d1ee245fea9740674e255 Mon Sep 17 00:00:00 2001 From: Anders Wiggers <47324009+anders-wiggers@users.noreply.github.com> Date: Sun, 17 May 2026 18:55:19 +0000 Subject: [PATCH 3/3] improved make_quarto_manifest handling of extra files and base directory. Added test for functionality. Removed abs / relative path gymnastics --- rsconnect/bundle.py | 20 ++++++------ tests/test_bundle.py | 74 +++++++++++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 5304000b..4960d992 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1396,7 +1396,7 @@ def make_quarto_manifest( """ if environment: extra_files = list(extra_files or []) - + base_dir = file_or_directory if isdir(file_or_directory): # Directory as a Quarto project. @@ -1427,13 +1427,15 @@ def make_quarto_manifest( # Use the common directory of the qmd and any extra files as base_dir. # This avoids having the subfolder appear in both base_dir and rel_path. all_files = [file_or_directory] + extra_files - - abs_paths = [os.path.abspath(p) for p in all_files] - base_dir = os.path.commonpath(abs_paths) - + base_dir = os.path.commonpath(all_files) + + # Ensure base_dir is a directory, not a file + if os.path.isfile(base_dir): + base_dir = os.path.dirname(base_dir) + # Store paths relative to base_dir - relevant_files = [os.path.relpath(p, base_dir) for p in abs_paths] - + relevant_files = [os.path.relpath(p, base_dir) for p in all_files] + manifest = make_source_manifest( app_mode, environment, @@ -1444,9 +1446,9 @@ def make_quarto_manifest( env_management_r, ) if environment: - manifest_add_buffer(manifest, environment.filename, environment.contents) + manifest_add_buffer(manifest, environment.filename, environment.contents) for rel_path in relevant_files: - manifest_add_file(manifest, rel_path, base_dir) + manifest_add_file(manifest, rel_path, base_dir) return manifest, relevant_files diff --git a/tests/test_bundle.py b/tests/test_bundle.py index a9f88196..b5419928 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -622,6 +622,53 @@ def test_make_quarto_source_bundle_from_file(self): }, ) + def test_make_quarto_source_bundle_from_file_with_extra_files(self): + temp_proj = tempfile.mkdtemp() + nested_dir = join(temp_proj, "subdir") + os.makedirs(nested_dir) + + filename = join(nested_dir, "myquarto.qmd") + with open(filename, "w") as fp: + fp.write("---\n") + fp.write("title: myquarto\n") + fp.write("engine: markdown\n") + fp.write("---\n\n") + fp.write("### This is a test\n") + + extra_filename = join(nested_dir, "data.csv") + with open(extra_filename, "w") as fp: + fp.write("a,b,c\n1,2,3\n") + + inspect = { + "quarto": {"version": "1.3.433"}, + "engines": ["markdown"], + } + + with make_quarto_source_bundle( + filename, + inspect, + AppModes.STATIC_QUARTO, + None, + [extra_filename], + [], + None, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + names = sorted(tar.getnames()) + self.assertEqual( + names, + [ + "data.csv", + "manifest.json", + "myquarto.qmd", + ], + ) + + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + self.assertEqual( + sorted(manifest["files"].keys()), + ["data.csv", "myquarto.qmd"], + ) + def test_list_files(self): # noinspection SpellCheckingInspection paths = [ @@ -1018,16 +1065,8 @@ def test_make_quarto_manifest_project_with_env(self): def test_make_quarto_manifest_project_with_extra_files(self): temp_proj = tempfile.mkdtemp() - # include extra_files parameter - fp = open(join(temp_proj, "a"), "w") - fp.write("This is file a\n") - fp.close() - fp = open(join(temp_proj, "b"), "w") - fp.write("This is file b\n") - fp.close() - fp = open(join(temp_proj, "c"), "w") - fp.write("This is file c\n") - fp.close() + for name, contents in [("a", "This is file a\n"), ("b", "This is file b\n"), ("c", "This is file c\n")]: + Path(temp_proj, name).write_text(contents) manifest, _ = make_quarto_manifest( temp_proj, @@ -1043,15 +1082,6 @@ def test_make_quarto_manifest_project_with_extra_files(self): None, ) - if sys.platform == "win32": - a_hash = "f4751c084b3ade4d736c6293ab8468c9" - b_hash = "4976d559975b5232cf09a10afaf8d0a8" - c_hash = "09c56e1b9e6ae34c6662717c47a7e187" - else: - a_hash = "4a3eb92956aa3e16a9f0a84a43c943e7" - b_hash = "b249e5b536d30e6282cea227f3a73669" - c_hash = "53b36f1d5b6f7fb2cfaf0c15af7ffb2d" - self.assertEqual( manifest, { @@ -1059,9 +1089,9 @@ def test_make_quarto_manifest_project_with_extra_files(self): "metadata": {"appmode": "quarto-shiny"}, "quarto": {"version": "0.9.16", "engines": ["jupyter"]}, "files": { - "a": {"checksum": a_hash}, - "b": {"checksum": b_hash}, - "c": {"checksum": c_hash}, + "a": {"checksum": mock.ANY}, + "b": {"checksum": mock.ANY}, + "c": {"checksum": mock.ANY}, }, }, )