Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
Union,
cast,
)
from urllib.parse import urlsplit

from . import metadata

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
62 changes: 52 additions & 10 deletions rsconnect/subprocesses/inspect_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
tdstein marked this conversation as resolved.
Resolved
result["python"] = python_version
result["pip"] = pip_version
result["locale"] = get_default_locale()
Expand Down Expand Up @@ -383,6 +383,27 @@ 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:
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}"
Comment thread
tdstein marked this conversation as resolved.
Resolved
lines.append(line)
return "\n".join(lines)


def is_rsconnect_requirement(line: str) -> bool:
target = editable_target(line)
if target is not None:
Expand All @@ -395,7 +416,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"):
Expand All @@ -419,14 +440,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:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
42 changes: 41 additions & 1 deletion tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 file:./local%20package[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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 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"):
Environment._inspect_environment(str(python), str(tmp_path), requirements_file=None)

Expand Down
70 changes: 67 additions & 3 deletions tests/test_pip_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -83,9 +84,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"
Expand All @@ -95,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"
Loading