From 8df6daf3fb1959f6e4892e7ff182faf8aaac704b Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Fri, 6 Nov 2026 13:01:27 -0500 Subject: [PATCH 1/2] fix: bundle editable dependencies inside deployment directory --- rsconnect/subprocesses/inspect_environment.py | 54 +++++++++++++++---- tests/test_environment.py | 42 ++++++++++++++- tests/test_pip_compatibility.py | 5 +- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 6703dab2..53ff58f7 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -109,7 +109,7 @@ def detect_environment( result = file_result if result is not None: - check_editables_are_bundleable(result["contents"] or "", dirname) + result["contents"] = normalize_editables(result["contents"] or "", dirname) result["python"] = python_version result["pip"] = pip_version result["locale"] = get_default_locale() @@ -383,6 +383,21 @@ def check_editables_are_bundleable(requirements: str, dirname: str): ) +def normalize_editables(requirements: str, dirname: str) -> str: + check_editables_are_bundleable(requirements, dirname) + lines = [] + for line in requirements.split("\n"): + target = editable_target(line) + if target: + relative = bundleable_editable_path(target, dirname) + if relative is not None: + line_ending = "\r" if line.endswith("\r") else "" + comment = re.search(r"\s+#.*$", line.rstrip("\r")) + line = f"-e {relative}{comment.group() if comment else ''}{line_ending}" + lines.append(line) + return "\n".join(lines) + + def is_rsconnect_requirement(line: str) -> bool: target = editable_target(line) if target is not None: @@ -395,7 +410,7 @@ def is_rsconnect_requirement(line: str) -> bool: def editable_target(line: str) -> Optional[str]: - line = line.strip() + line = re.split(r"\s+#", line.strip(), maxsplit=1)[0].rstrip() if line.startswith("--editable="): return line[len("--editable=") :].strip() for flag in ("-e", "--editable"): @@ -419,14 +434,35 @@ def is_unbundleable_editable(line: str, dirname: str) -> bool: target = editable_target(line) if not target: return False + if is_path(target) or urlsplit(target).scheme.lower() == "file": + return bundleable_editable_path(target, dirname) is None + return urlsplit(target).scheme.lower().endswith("+file") + + +def bundleable_editable_path(target: str, dirname: str) -> Optional[str]: if not is_path(target): - scheme = urlsplit(target).scheme.lower() - return scheme == "file" or scheme.endswith("+file") - # Requirements may come from Windows or POSIX publishers; either kind of absolute path only exists locally. - if posixpath.isabs(target) or ntpath.isabs(target): - return True - bundle_dir = os.path.abspath(dirname) - return os.path.commonpath([bundle_dir, os.path.abspath(os.path.join(bundle_dir, target))]) != bundle_dir + url = urlsplit(target) + if url.scheme.lower() != "file" or url.netloc not in ("", "localhost") or url.query or url.fragment: + return None + target = url2pathname(url.path) + + extras = re.search(r"\[[a-zA-Z0-9_,.\-\s]+\]$", target) + suffix = extras.group() if extras else "" + if extras: + target = target[: extras.start()] + + # Reject paths in another platform's absolute path syntax. + if (posixpath.isabs(target) or ntpath.isabs(target)) and not os.path.isabs(target): + return None + bundle_dir = os.path.realpath(dirname) + path = os.path.realpath(os.path.join(bundle_dir, target)) + try: + if os.path.commonpath([bundle_dir, path]) != bundle_dir: + return None + except ValueError: + return None + relative = os.path.relpath(path, bundle_dir).replace(os.sep, "/") + return ("." if relative == "." else f"./{relative}") + suffix def is_path(target: str) -> bool: diff --git a/tests/test_environment.py b/tests/test_environment.py index 7c44b0df..8f1e2bf7 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -320,6 +320,42 @@ def test_requirements_file_rejects_editable_outside_bundle(tmp_path: Path): assert "-e /Users/me/localpkg" in str(error.value) +@pytest.mark.parametrize("use_url", [False, True]) +def test_requirements_file_rewrites_editables_inside_bundle(tmp_path: Path, use_url): + local = tmp_path / "local package" + local.mkdir() + + def target(path): + return path.as_uri() if use_url else str(path) + + (tmp_path / "requirements.txt").write_bytes( + f"-e {target(tmp_path)} # project\r\n--editable={target(local)}[extra]\r\n" + "-e git+https://example.com/repo.git#egg=remote\r\n".encode() + ) + + env = Environment.create_python_environment(str(tmp_path)) + + assert ( + env.contents + == "-e . # project\r\n-e ./local package[extra]\r\n-e git+https://example.com/repo.git#egg=remote\r\n" + ) + + +def test_requirements_file_rejects_editable_symlink_outside_bundle(tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + try: + (project / "linked").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("Directory symlinks are unavailable") + (project / "requirements.txt").write_text("-e ./linked[extra]\n") + + with pytest.raises(RSConnectException, match="Editable dependencies outside the deployment directory"): + Environment.create_python_environment(str(project)) + + @pytest.mark.parametrize("fallback", [False, True]) def test_uv_lock_export(tmp_path, monkeypatch, fallback): project_dir = tmp_path / "project" @@ -797,7 +833,7 @@ def test_inspection_when_rsconnect_is_importable_only_from_pythonpath(tmp_path): @pytest.mark.parametrize("fallback", [False, True]) -def test_uv_freeze_filters_distributions_and_rejects_editable_file_urls(tmp_path, monkeypatch, fallback): +def test_uv_freeze_filters_distributions_and_rewrites_editable_file_urls(tmp_path, monkeypatch, fallback): uv_path = uv.find_uv_bin() env_dir = tmp_path / "env" subprocess.run([uv_path, "venv", "--python", sys.executable, str(env_dir)], check=True) @@ -830,6 +866,10 @@ def test_uv_freeze_filters_distributions_and_rejects_editable_file_urls(tmp_path dist.mkdir() (dist / "METADATA").write_text("Metadata-Version: 2.1\nName: editable-project\nVersion: 1.0\n") (dist / "direct_url.json").write_text(json.dumps({"url": local.as_uri(), "dir_info": {"editable": True}})) + environment = Environment._inspect_environment(str(python), str(tmp_path), requirements_file=None) + assert "-e ./editable project" in environment.contents.splitlines() + + (dist / "direct_url.json").write_text(json.dumps({"url": tmp_path.parent.as_uri(), "dir_info": {"editable": True}})) with pytest.raises(RSConnectException, match="Editable dependencies outside the deployment directory"): Environment._inspect_environment(str(python), str(tmp_path), requirements_file=None) diff --git a/tests/test_pip_compatibility.py b/tests/test_pip_compatibility.py index f548db80..05dfbdb7 100644 --- a/tests/test_pip_compatibility.py +++ b/tests/test_pip_compatibility.py @@ -83,9 +83,8 @@ def inspect(requirements_file): metadata.mkdir() (metadata / "METADATA").write_text("Metadata-Version: 2.1\nName: demo-local\nVersion: 1.0\n") (metadata / "direct_url.json").write_text(json.dumps({"url": local.as_uri(), "dir_info": {"editable": True}})) - rejected = inspect("none") - assert "Editable dependencies outside the deployment directory" in rejected["error"] - assert "rsconnect-local" in rejected["error"] + generated = inspect("none") + assert "-e ./rsconnect-local" in generated["contents"].splitlines() (tmp_path / "requirements.txt").write_text( "rsconnect==1.0\nrsconnect-python==1.0\nrsconnect-utils==1.0\n" From dc4fe07c7fccfc189f2a5c00d344e3892b48820b Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Fri, 6 Nov 2026 14:08:15 -0500 Subject: [PATCH 2/2] fix: handle editable paths in notebook and directory bundles --- rsconnect/bundle.py | 21 ++++++ rsconnect/subprocesses/inspect_environment.py | 8 ++- tests/test_bundle.py | 26 ++++++++ tests/test_environment.py | 4 +- tests/test_pip_compatibility.py | 65 +++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 786fb449..8e7e56e5 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -41,6 +41,7 @@ Union, cast, ) +from urllib.parse import urlsplit from . import metadata @@ -60,6 +61,7 @@ from .log import VERBOSE, logger, warn_user from .models import AppMode, AppModes, GlobSet from .shiny_express import escape_to_var_name, is_express_app +from .subprocesses.inspect_environment import editable_target, is_path if TYPE_CHECKING: from .actions import QuartoInspectResult @@ -570,6 +572,20 @@ def bundle_add_buffer(bundle: tarfile.TarFile, filename: str, contents: str | by bundle.addfile(file_info, buf) +def _check_single_notebook_editables(environment: Environment) -> None: + local = [] + for line in environment.contents.splitlines(): + target = editable_target(line) + if target and (is_path(target) or urlsplit(target).scheme.lower() == "file"): + local.append(line.strip()) + if local: + raise RSConnectException( + "Local editable dependencies are not included in a single-notebook bundle:\n" + + "\n".join(local) + + "\nDeploy a directory containing the packages, or use a package index or VCS URL." + ) + + def write_manifest( relative_dir: str, nb_name: str, @@ -588,6 +604,7 @@ def write_manifest( Returns the list of filenames written. """ + _check_single_notebook_editables(environment) manifest_filename = "manifest.json" manifest = make_source_manifest( AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, image, env_management_py, env_management_r @@ -673,6 +690,7 @@ def make_notebook_source_bundle( :param r_environment: optional R dependencies detected from renv.lock to add to the manifest. """ + _check_single_notebook_editables(environment) if extra_files is None: extra_files = [] base_dir = dirname(file) @@ -2136,6 +2154,7 @@ def write_notebook_manifest_json( :return: whether or not the environment file (requirements.txt, environment.yml, etc.) that goes along with the manifest exists. """ + _check_single_notebook_editables(environment) directory = dirname(entry_point_file) config_dir = _resolved_config_dir() _ensure_content_root_is_safe(directory, config_dir) @@ -2224,6 +2243,8 @@ def create_voila_manifest( """ if not path: raise RSConnectException("A valid path must be provided.") + if isfile(path): + _check_single_notebook_editables(environment) extra_files = list(extra_files) if extra_files else [] entrypoint_candidates = infer_entrypoint_candidates(path=abspath(path), mimetype="text/ipynb") diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 53ff58f7..07e8bb9c 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -22,7 +22,7 @@ import sys from dataclasses import asdict, dataclass, replace from typing import Any, Callable, List, Optional, cast -from urllib.parse import parse_qs, urlsplit +from urllib.parse import parse_qs, quote, urlsplit from urllib.request import url2pathname from packaging.requirements import InvalidRequirement, Requirement @@ -391,6 +391,12 @@ def normalize_editables(requirements: str, dirname: str) -> str: if target: relative = bundleable_editable_path(target, dirname) if relative is not None: + extras = re.search(r"\[[a-zA-Z0-9_,.\-\s]+\]$", relative) + suffix = extras.group() if extras else "" + path = relative[: extras.start()] if extras else relative + escaped = quote(path, safe="/") + if escaped != path: + relative = f"file:{escaped}{suffix}" line_ending = "\r" if line.endswith("\r") else "" comment = re.search(r"\s+#.*$", line.rstrip("\r")) line = f"-e {relative}{comment.group() if comment else ''}{line_ending}" diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 6cef1139..45a74b5c 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -4001,6 +4001,32 @@ def make_generated_environment(contents: str) -> Environment: return environment +@pytest.mark.parametrize("requirement", ["-e .", "-e file:./local%20package"]) +@pytest.mark.parametrize("kind", ["notebook_bundle", "notebook_manifest", "voila_file"]) +def test_single_notebook_rejects_local_editable(tmp_path: Path, requirement: str, kind: str): + notebook = tmp_path / "notebook.ipynb" + notebook.write_text('{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}') + environment = make_generated_environment(f"{requirement}\n") + + with pytest.raises(RSConnectException, match="Local editable dependencies are not included"): + if kind == "notebook_bundle": + make_notebook_source_bundle(str(notebook), environment, [], False, False) + elif kind == "notebook_manifest": + write_notebook_manifest_json(str(notebook), environment, AppModes.JUPYTER_NOTEBOOK, [], False, False) + else: + make_voila_bundle(str(notebook), None, [], [], True, environment) + + +def test_voila_directory_includes_local_editable(tmp_path: Path): + (tmp_path / "notebook.ipynb").write_text('{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}') + (tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()\n") + environment = make_generated_environment("-e .\n") + + with make_voila_bundle(str(tmp_path), None, [], [], True, environment) as bundle: + with tarfile.open(fileobj=bundle, mode="r:gz") as tar: + assert "setup.py" in tar.getnames() + + def test_write_api_manifest_checksum_matches_environment_file(tmp_path: Path, windows_text_mode: None): (tmp_path / "app.py").write_text("from shiny import App\n") environment = make_generated_environment("# café\nshiny==1.0.0\nnumpy\n") diff --git a/tests/test_environment.py b/tests/test_environment.py index 8f1e2bf7..d8b20c21 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -337,7 +337,7 @@ def target(path): assert ( env.contents - == "-e . # project\r\n-e ./local package[extra]\r\n-e git+https://example.com/repo.git#egg=remote\r\n" + == "-e . # project\r\n-e file:./local%20package[extra]\r\n-e git+https://example.com/repo.git#egg=remote\r\n" ) @@ -867,7 +867,7 @@ def test_uv_freeze_filters_distributions_and_rewrites_editable_file_urls(tmp_pat (dist / "METADATA").write_text("Metadata-Version: 2.1\nName: editable-project\nVersion: 1.0\n") (dist / "direct_url.json").write_text(json.dumps({"url": local.as_uri(), "dir_info": {"editable": True}})) environment = Environment._inspect_environment(str(python), str(tmp_path), requirements_file=None) - assert "-e ./editable project" in environment.contents.splitlines() + assert "-e file:./editable%20project" in environment.contents.splitlines() (dist / "direct_url.json").write_text(json.dumps({"url": tmp_path.parent.as_uri(), "dir_info": {"editable": True}})) with pytest.raises(RSConnectException, match="Editable dependencies outside the deployment directory"): diff --git a/tests/test_pip_compatibility.py b/tests/test_pip_compatibility.py index 05dfbdb7..fe4128a7 100644 --- a/tests/test_pip_compatibility.py +++ b/tests/test_pip_compatibility.py @@ -9,6 +9,7 @@ import pytest import uv +from rsconnect.environment import Environment @pytest.mark.skipif(not os.environ.get("RSCONNECT_COMPAT_PYTHON"), reason="No compatibility interpreter selected") @@ -94,3 +95,67 @@ def inspect(requirements_file): assert requirements["contents"] == ( "rsconnect-utils==1.0\ndemo-url @ https://example.com/rsconnect/demo.whl\n-e ./rsconnect-local\n" ) + + +@pytest.mark.skipif(not os.environ.get("RSCONNECT_COMPAT_PYTHON"), reason="No compatibility interpreter selected") +@pytest.mark.parametrize("installer", ["pip", "uv"]) +def test_rewritten_editable_with_spaces_installs(tmp_path: Path, installer: str): + base_python = os.path.abspath(os.environ["RSCONNECT_COMPAT_PYTHON"]) + project = tmp_path / "project" + package = project / "local package" + package.mkdir(parents=True) + (package / "setup.py").write_text( + "from setuptools import setup\nsetup(name='local-package', version='1.0', py_modules=['local_module'])\n" + ) + (package / "local_module.py").write_text("value = 42\n") + (project / "requirements.txt").write_text(f"-e {package.as_uri()}\n") + requirements = Environment.create_python_environment(str(project)).contents + assert requirements == "-e file:./local%20package\n" + + deployed = tmp_path / "deployed" + shutil.copytree(project, deployed) + (deployed / "requirements.txt").write_text(requirements) + env_dir = tmp_path / "env" + subprocess.run([base_python, "-m", "venv", "--without-pip", str(env_dir)], check=True) + python = str(env_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) + site = Path( + subprocess.check_output( + [python, "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"], text=True + ).strip() + ) + module_paths = json.loads( + subprocess.check_output( + [ + base_python, + "-c", + "import importlib.util, json; " + "print(json.dumps({n: (s.origin if (s := importlib.util.find_spec(n)) else None) " + "for n in ('pip', 'setuptools', 'wheel', '_distutils_hack')}))", + ], + text=True, + ) + ) + if not all(module_paths.values()): + pytest.skip("Compatibility interpreter needs pip, setuptools, and wheel for editable installation") + for name in ("pip", "setuptools", "wheel", "_distutils_hack"): + shutil.copytree(Path(module_paths[name]).parent, site / name) + for pattern in ("pip-*.dist-info", "setuptools-*.dist-info", "wheel-*.dist-info", "distutils-precedence.pth"): + for base_site in {Path(path).parent.parent for path in module_paths.values()}: + for item in base_site.glob(pattern): + if item.is_dir(): + shutil.copytree(item, site / item.name, dirs_exist_ok=True) + else: + shutil.copy2(item, site / item.name) + + command = ( + [python, "-m", "pip", "install"] + if installer == "pip" + else [uv.find_uv_bin(), "pip", "install", "--python", python] + ) + subprocess.run( + command + ["--no-deps", "--no-build-isolation", "-r", "requirements.txt"], + cwd=deployed, + check=True, + ) + result = subprocess.check_output([python, "-c", "import local_module; print(local_module.value)"], text=True) + assert result.strip() == "42"