From 862ffaf718ed0f6dc43568f915c88d767a5879ef Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 12:05:04 -0400 Subject: [PATCH 1/9] fix: correct requirement filtering for local installs --- docs/CHANGELOG.md | 3 ++ rsconnect/subprocesses/inspect_environment.py | 22 +++++++-- tests/test_environment.py | 46 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ba110352..b5b8e400 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Generated requirements omit local editable installs, which cannot be installed + on Connect. Requirement filtering no longer drops unrelated packages merely + because their name or path contains `rsconnect`, including in user-provided files. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API requests with your login. `--refresh` gets a new token after the server rejects diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index ab0f598a..92a88b0d 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -28,6 +28,9 @@ import toml as tomllib # type: ignore[no-redef] version_re = re.compile(r"\d+\.\d+(\.\d+)?") +rsconnect_requirement_re = re.compile(r"^\s*rsconnect[-_.]+python(?=\s*(?:[=<>!~@;\[]|$))", re.IGNORECASE) +editable_re = re.compile(r"^\s*(?:-e\s+|--editable(?:\s+|=))(.+?)\s*$") +remote_url_re = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) exec_dir = os.path.dirname(sys.executable) @@ -152,7 +155,7 @@ def output_file(dirname: str, filename: str, package_manager: str): with open(path, "r", newline="") as f: data = f.read() - data = "\n".join([line for line in data.split("\n") if "rsconnect" not in line]) + data = "\n".join(line for line in data.split("\n") if not rsconnect_requirement_re.match(line)) return { "filename": filename, @@ -307,12 +310,25 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): def filter_pip_freeze_output(pip_stdout: str): - # Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]` return "\n".join( - [line for line in pip_stdout.split("\n") if (("rsconnect" not in line) and (line.find("[notice]") != 0))] + line + for line in pip_stdout.split("\n") + if not rsconnect_requirement_re.match(line) + and not line.startswith("[notice]") + and not line.startswith("# Editable install with no version control (") + and not is_local_editable(line) ) +def is_local_editable(line: str) -> bool: + match = editable_re.match(line) + if not match: + return False + target = match.group(1) + scheme = target.split("://", 1)[0].lower() + return not remote_url_re.match(target) or scheme == "file" or scheme.endswith("+file") + + def strip_ref(line: str): # remove erroneous conda build paths that will break pip install # Keep a CRLF line ending. The caller splits on "\n", so the "\r" stays on the line. diff --git a/tests/test_environment.py b/tests/test_environment.py index cbf364b9..9bffe458 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -75,14 +75,14 @@ def test_requirements_override(self): os.makedirs(os.path.join(project_dir, "alt"), exist_ok=True) custom_requirements = os.path.join(project_dir, "alt", "custom.txt") with open(custom_requirements, "w", newline="\n") as f: - f.write("foo==1.0\nbar>=2.0\nrsconnect==0.1\n") + f.write("foo==1.0\nbar>=2.0\nrsconnect-python==0.1\nrsconnect==0.1\n") result = Environment.create_python_environment( project_dir, requirements_file=os.path.join("alt", "custom.txt") ) assert result.filename.endswith("custom.txt") - assert result.contents == "foo==1.0\nbar>=2.0\n" + assert result.contents == "foo==1.0\nbar>=2.0\nrsconnect==0.1\n" assert result.source == "file" def test_pip_freeze(self): @@ -133,6 +133,48 @@ def test_filter_pip_freeze_output(self): self.assertEqual(filtered, expected) +def test_filter_generated_requirements(): + raw = "\n".join( + [ + "rsconnect-python==1.0", + "RSCONNECT_PYTHON @ https://example.com/rsconnect.whl", + "rsconnect.python>=1", + "rsconnect-python", + "my-rsconnect-utils==0.1", + "rsconnect-python-extra==1.0", + "demo @ file:///work/rsconnect-demo", + "# Editable install with no version control (demo==1.0)", + "-e /work/rsconnect-demo", + "-e ./local-project", + "--editable=file:///work/project", + "-e git+file:///work/repo#egg=project", + "-e git+https://example.com/repo.git#egg=remote", + "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "# my rsconnect project", + ] + ) + + assert filter_pip_freeze_output(raw).splitlines() == [ + "my-rsconnect-utils==0.1", + "rsconnect-python-extra==1.0", + "demo @ file:///work/rsconnect-demo", + "-e git+https://example.com/repo.git#egg=remote", + "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "# my rsconnect project", + ] + + +def test_requirements_file_filters_only_rsconnect_distribution(tmp_path: Path): + (tmp_path / "requirements.txt").write_bytes( + b"RSCONNECT_PYTHON==1.0\r\nmy-rsconnect-utils==0.1\r\n" + b"demo @ file:///work/rsconnect-demo\r\n# rsconnect note\r\n-e /work/local\r\n" + ) + + env = Environment.create_python_environment(str(tmp_path)) + + assert env.contents == "my-rsconnect-utils==0.1\r\ndemo\r\n# rsconnect note\r\n-e /work/local\r\n" + + def test_uv_lock_export(tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() From 89646d81bc2c14b485cfc67ff4acf3cc5622660d Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 14:38:50 -0400 Subject: [PATCH 2/9] fix: preserve non-hierarchical editable VCS URLs --- rsconnect/subprocesses/inspect_environment.py | 4 ++-- tests/test_environment.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 8743e60a..7283c91c 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -30,7 +30,7 @@ version_re = re.compile(r"\d+\.\d+(\.\d+)?") rsconnect_requirement_re = re.compile(r"^\s*rsconnect[-_.]+python(?=\s*(?:[=<>!~@;\[]|$))", re.IGNORECASE) editable_re = re.compile(r"^\s*(?:-e\s+|--editable(?:\s+|=))(.+?)\s*$") -remote_url_re = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) +remote_url_re = re.compile(r"^(?:[a-z][a-z0-9+.-]*://|(?:bzr|git|hg|svn)\+[a-z][a-z0-9+.-]*:)", re.IGNORECASE) exec_dir = os.path.dirname(sys.executable) @@ -327,7 +327,7 @@ def is_local_editable(line: str) -> bool: if not match: return False target = match.group(1) - scheme = target.split("://", 1)[0].lower() + scheme = target.split(":", 1)[0].lower() return not remote_url_re.match(target) or scheme == "file" or scheme.endswith("+file") diff --git a/tests/test_environment.py b/tests/test_environment.py index a726b40e..57e6bace 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -148,8 +148,12 @@ def test_filter_generated_requirements(): "-e ./local-project", "--editable=file:///work/project", "-e git+file:///work/repo#egg=project", + "-e git+file:/work/repo#egg=project", + "-e C:\\work\\project", "-e git+https://example.com/repo.git#egg=remote", "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "-e bzr+lp:project#egg=remote-bzr", + "-e git+https:repo#egg=remote-git", "# my rsconnect project", ] ) @@ -160,6 +164,8 @@ def test_filter_generated_requirements(): "demo @ file:///work/rsconnect-demo", "-e git+https://example.com/repo.git#egg=remote", "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "-e bzr+lp:project#egg=remote-bzr", + "-e git+https:repo#egg=remote-git", "# my rsconnect project", ] From d415b5a5e150f1676f06994e0a4cf7bf79f2a746 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 29 Oct 2026 12:55:48 -0400 Subject: [PATCH 3/9] fix: report local editable dependencies during inspection --- docs/CHANGELOG.md | 6 +-- rsconnect/subprocesses/inspect_environment.py | 9 +++- tests/test_environment.py | 49 ++++++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b5b8e400..92ffe4cd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Generated requirements omit local editable installs, which cannot be installed - on Connect. Requirement filtering no longer drops unrelated packages merely - because their name or path contains `rsconnect`, including in user-provided files. +- ([#899](https://github.com/posit-dev/rsconnect-python/issues/899)): Reports local + editable dependencies that Posit Connect cannot install. Requirement filtering no + longer drops unrelated packages whose name or path contains `rsconnect`. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API requests with your login. `--refresh` gets a new token after the server rejects diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 7283c91c..2a42d6de 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -312,13 +312,20 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): def filter_pip_freeze_output(pip_stdout: str): + local_editables = [line.strip() for line in pip_stdout.split("\n") if is_local_editable(line)] + if local_editables: + raise EnvironmentException( + "Local editable dependencies cannot be installed on Connect:\n" + + "\n".join(local_editables) + + "\nProvide a requirements file with installable dependencies, " + "or replace these installs with packages available to Connect." + ) return "\n".join( line for line in pip_stdout.split("\n") if not rsconnect_requirement_re.match(line) and not line.startswith("[notice]") and not line.startswith("# Editable install with no version control (") - and not is_local_editable(line) ) diff --git a/tests/test_environment.py b/tests/test_environment.py index 57e6bace..04dbd65c 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -144,12 +144,6 @@ def test_filter_generated_requirements(): "rsconnect-python-extra==1.0", "demo @ file:///work/rsconnect-demo", "# Editable install with no version control (demo==1.0)", - "-e /work/rsconnect-demo", - "-e ./local-project", - "--editable=file:///work/project", - "-e git+file:///work/repo#egg=project", - "-e git+file:/work/repo#egg=project", - "-e C:\\work\\project", "-e git+https://example.com/repo.git#egg=remote", "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", "-e bzr+lp:project#egg=remote-bzr", @@ -170,6 +164,49 @@ def test_filter_generated_requirements(): ] +@pytest.mark.parametrize( + "requirement", + [ + "-e /work/rsconnect-demo", + "-e ./local-project", + "--editable=file:///work/project", + "-e git+file:///work/repo#egg=project", + "-e git+file:/work/repo#egg=project", + "-e C:\\work\\project", + ], +) +def test_filter_generated_requirements_rejects_local_editables(requirement): + with pytest.raises(EnvironmentException) as error: + filter_pip_freeze_output(f"# Editable install with no version control (demo==1.0)\n{requirement}\n") + + assert requirement in str(error.value) + assert "requirements file" in str(error.value) + + +def test_filter_generated_requirements_reports_all_local_editables(): + with pytest.raises(EnvironmentException) as error: + filter_pip_freeze_output("-e /work/one\n-e /work/two") + + assert "-e /work/one" in str(error.value) + assert "-e /work/two" in str(error.value) + + +def test_inspect_environment_reports_local_editable(tmp_path: Path, monkeypatch): + pip_dir = tmp_path / "pip" + pip_dir.mkdir() + (pip_dir / "__init__.py").write_text("") + (pip_dir / "__main__.py").write_text( + "print('# Editable install with no version control (demo==1.0)')\nprint('-e /work/demo')\n" + ) + monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(tmp_path), str(Path(__file__).resolve().parents[1])])) + + with pytest.raises(RSConnectException, match="Local editable dependencies cannot be installed on Connect") as error: + Environment._inspect_environment(sys.executable, str(tmp_path), requirements_file=None) + + assert "-e /work/demo" in str(error.value) + assert "requirements file" in str(error.value) + + def test_requirements_file_filters_only_rsconnect_distribution(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes( b"RSCONNECT_PYTHON==1.0\r\nmy-rsconnect-utils==0.1\r\n" From 0811d19ac6eda04cc97286fe0996d47ea1dbc63b Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 29 Oct 2026 13:18:30 -0400 Subject: [PATCH 4/9] docs: follow changelog issue reference convention --- docs/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92ffe4cd..bcb28ecf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,9 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- ([#899](https://github.com/posit-dev/rsconnect-python/issues/899)): Reports local - editable dependencies that Posit Connect cannot install. Requirement filtering no - longer drops unrelated packages whose name or path contains `rsconnect`. +- Reports local editable dependencies that Posit Connect cannot install. Requirement + filtering no longer drops unrelated packages whose name or path contains `rsconnect`. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API requests with your login. `--refresh` gets a new token after the server rejects From 5fd7a6153c632e76dd734cd322d25d72e9c1cd58 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 29 Oct 2026 13:49:54 -0400 Subject: [PATCH 5/9] fix: reject editable dependencies Connect cannot reach Parse requirement names with packaging instead of regexes, and classify editable targets with urlsplit. Editable installs of rsconnect-python itself are dropped like other rsconnect-python requirements. Editables are now checked for every requirements source, including user-provided files. Absolute paths, file: URLs, and relative paths that leave the deployment directory raise an error; relative paths inside the bundle, such as uv workspace members, are allowed. Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 3 +- rsconnect/subprocesses/inspect_environment.py | 86 +++++++++++++++---- tests/test_environment.py | 73 ++++++++++++++-- 3 files changed, 134 insertions(+), 28 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bcb28ecf..29ac3ea8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Reports local editable dependencies that Posit Connect cannot install. Requirement +- Reports editable dependencies outside the deployment directory, which Posit Connect + cannot install, in both generated and user-provided requirements. Requirement filtering no longer drops unrelated packages whose name or path contains `rsconnect`. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 2a42d6de..939dc697 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -13,13 +13,19 @@ import datetime import json import locale +import ntpath import os +import posixpath import tempfile import re import subprocess import sys from dataclasses import asdict, dataclass, replace from typing import Callable, List, Optional, cast +from urllib.parse import parse_qs, urlsplit + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name try: import tomllib @@ -28,10 +34,8 @@ import toml as tomllib # type: ignore[no-redef] version_re = re.compile(r"\d+\.\d+(\.\d+)?") -rsconnect_requirement_re = re.compile(r"^\s*rsconnect[-_.]+python(?=\s*(?:[=<>!~@;\[]|$))", re.IGNORECASE) -editable_re = re.compile(r"^\s*(?:-e\s+|--editable(?:\s+|=))(.+?)\s*$") -remote_url_re = re.compile(r"^(?:[a-z][a-z0-9+.-]*://|(?:bzr|git|hg|svn)\+[a-z][a-z0-9+.-]*:)", re.IGNORECASE) exec_dir = os.path.dirname(sys.executable) +rsconnect_project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @dataclass(frozen=True) @@ -99,6 +103,7 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require ) if result is not None: + check_editables_are_bundleable(result["contents"], dirname) result["python"] = get_python_version() result["pip"] = get_version("pip") result["locale"] = get_default_locale() @@ -155,7 +160,7 @@ def output_file(dirname: str, filename: str, package_manager: str): with open(path, "r", newline="") as f: data = f.read() - data = "\n".join(line for line in data.split("\n") if not rsconnect_requirement_re.match(line)) + data = "\n".join(line for line in data.split("\n") if not is_rsconnect_requirement(line)) return { "filename": filename, @@ -312,30 +317,73 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): def filter_pip_freeze_output(pip_stdout: str): - local_editables = [line.strip() for line in pip_stdout.split("\n") if is_local_editable(line)] - if local_editables: - raise EnvironmentException( - "Local editable dependencies cannot be installed on Connect:\n" - + "\n".join(local_editables) - + "\nProvide a requirements file with installable dependencies, " - "or replace these installs with packages available to Connect." - ) return "\n".join( line for line in pip_stdout.split("\n") - if not rsconnect_requirement_re.match(line) + if not is_rsconnect_requirement(line) and not line.startswith("[notice]") and not line.startswith("# Editable install with no version control (") ) -def is_local_editable(line: str) -> bool: - match = editable_re.match(line) - if not match: +def check_editables_are_bundleable(requirements: str, dirname: str): + unbundleable = [line.strip() for line in requirements.split("\n") if is_unbundleable_editable(line, dirname)] + if unbundleable: + raise EnvironmentException( + "Editable dependencies outside the deployment directory cannot be installed on Connect:\n" + + "\n".join(unbundleable) + + "\nProvide a requirements file that installs them from a package index, a VCS URL, " + "or a relative path inside the deployment directory." + ) + + +def is_rsconnect_requirement(line: str) -> bool: + target = editable_target(line) + if target is not None: + return is_rsconnect_editable(target) + try: + requirement = Requirement(line.strip()) + except InvalidRequirement: + return False + return canonicalize_name(requirement.name) == "rsconnect-python" + + +def editable_target(line: str) -> Optional[str]: + line = line.strip() + if line.startswith("--editable="): + return line[len("--editable=") :].strip() + for flag in ("-e", "--editable"): + if line.startswith(flag) and line[len(flag) : len(flag) + 1].isspace(): + return line[len(flag) :].strip() + return None + + +def is_rsconnect_editable(target: str) -> bool: + url = urlsplit(target) + # pip freeze names VCS editables with an #egg= fragment; local checkouts are only identifiable by path. + egg = parse_qs(url.fragment).get("egg") + if egg: + return canonicalize_name(egg[0]) == "rsconnect-python" + return is_path(target) and os.path.isdir(target) and os.path.samefile(target, rsconnect_project_dir) + + +def is_unbundleable_editable(line: str, dirname: str) -> bool: + target = editable_target(line) + if not target: return False - target = match.group(1) - scheme = target.split(":", 1)[0].lower() - return not remote_url_re.match(target) or scheme == "file" or scheme.endswith("+file") + 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 + + +def is_path(target: str) -> bool: + # PEP 508 URLs use RFC 3986 schemes; a one-letter scheme is a Windows drive, so the target is a path. + return len(urlsplit(target).scheme) <= 1 def strip_ref(line: str): diff --git a/tests/test_environment.py b/tests/test_environment.py index 04dbd65c..8aed27a5 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -14,7 +14,9 @@ from rsconnect.subprocesses.inspect_environment import ( EnvironmentException, detect_environment, + check_editables_are_bundleable, filter_pip_freeze_output, + rsconnect_project_dir, get_default_locale, get_python_version, ) @@ -140,6 +142,8 @@ def test_filter_generated_requirements(): "RSCONNECT_PYTHON @ https://example.com/rsconnect.whl", "rsconnect.python>=1", "rsconnect-python", + " Rsconnect_-Python[extra] ; python_version >= '3.8'", + "-rsconnect-python", "my-rsconnect-utils==0.1", "rsconnect-python-extra==1.0", "demo @ file:///work/rsconnect-demo", @@ -148,11 +152,15 @@ def test_filter_generated_requirements(): "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", "-e bzr+lp:project#egg=remote-bzr", "-e git+https:repo#egg=remote-git", + "-e hg+http://example.com/repo#egg=remote-hg", + "--editable=svn+svn://example.com/repo#egg=remote-svn", + "-e https://example.com/project.tar.gz#egg=remote-archive", "# my rsconnect project", ] ) assert filter_pip_freeze_output(raw).splitlines() == [ + "-rsconnect-python", "my-rsconnect-utils==0.1", "rsconnect-python-extra==1.0", "demo @ file:///work/rsconnect-demo", @@ -160,6 +168,9 @@ def test_filter_generated_requirements(): "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", "-e bzr+lp:project#egg=remote-bzr", "-e git+https:repo#egg=remote-git", + "-e hg+http://example.com/repo#egg=remote-hg", + "--editable=svn+svn://example.com/repo#egg=remote-svn", + "-e https://example.com/project.tar.gz#egg=remote-archive", "# my rsconnect project", ] @@ -168,24 +179,55 @@ def test_filter_generated_requirements(): "requirement", [ "-e /work/rsconnect-demo", - "-e ./local-project", + "-e ../outside-project", + "-e ./libs/../../outside-project", "--editable=file:///work/project", "-e git+file:///work/repo#egg=project", "-e git+file:/work/repo#egg=project", "-e C:\\work\\project", + "-e D:/work/project", + "-e FILE:///work/project", ], ) -def test_filter_generated_requirements_rejects_local_editables(requirement): +def test_check_editables_rejects_editables_outside_bundle(tmp_path: Path, requirement): with pytest.raises(EnvironmentException) as error: - filter_pip_freeze_output(f"# Editable install with no version control (demo==1.0)\n{requirement}\n") + check_editables_are_bundleable(f"six==1.17.0\n{requirement}\n", str(tmp_path)) assert requirement in str(error.value) assert "requirements file" in str(error.value) -def test_filter_generated_requirements_reports_all_local_editables(): +@pytest.mark.parametrize( + "requirement", + [ + "-e ./libs/foo", + "-e libs/foo", + "--editable=.", + "-e git+https://example.com/repo.git#egg=remote", + "-e bzr+lp:project#egg=remote-bzr", + "six @ file:///work/six", + ], +) +def test_check_editables_allows_bundleable_requirements(tmp_path: Path, requirement): + check_editables_are_bundleable(f"{requirement}\r\n", str(tmp_path)) + + +def test_filter_generated_requirements_drops_editable_rsconnect(): + raw = "\n".join( + [ + "six==1.17.0", + "-e git+https://github.com/posit-dev/rsconnect-python.git@abc123#egg=rsconnect_python", + "# Editable install with no version control (rsconnect-python==1.0)", + f"-e {rsconnect_project_dir}", + ] + ) + + assert filter_pip_freeze_output(raw) == "six==1.17.0" + + +def test_check_editables_reports_all_unbundleable_editables(tmp_path: Path): with pytest.raises(EnvironmentException) as error: - filter_pip_freeze_output("-e /work/one\n-e /work/two") + check_editables_are_bundleable("-e /work/one\n-e /work/two", str(tmp_path)) assert "-e /work/one" in str(error.value) assert "-e /work/two" in str(error.value) @@ -200,7 +242,10 @@ def test_inspect_environment_reports_local_editable(tmp_path: Path, monkeypatch) ) monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(tmp_path), str(Path(__file__).resolve().parents[1])])) - with pytest.raises(RSConnectException, match="Local editable dependencies cannot be installed on Connect") as error: + with pytest.raises( + RSConnectException, + match="Editable dependencies outside the deployment directory cannot be installed on Connect", + ) as error: Environment._inspect_environment(sys.executable, str(tmp_path), requirements_file=None) assert "-e /work/demo" in str(error.value) @@ -210,12 +255,24 @@ def test_inspect_environment_reports_local_editable(tmp_path: Path, monkeypatch) def test_requirements_file_filters_only_rsconnect_distribution(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes( b"RSCONNECT_PYTHON==1.0\r\nmy-rsconnect-utils==0.1\r\n" - b"demo @ file:///work/rsconnect-demo\r\n# rsconnect note\r\n-e /work/local\r\n" + b"demo @ file:///work/rsconnect-demo\r\n# rsconnect note\r\n-e ./libs/local\r\n" ) env = Environment.create_python_environment(str(tmp_path)) - assert env.contents == "my-rsconnect-utils==0.1\r\ndemo\r\n# rsconnect note\r\n-e /work/local\r\n" + assert env.contents == "my-rsconnect-utils==0.1\r\ndemo\r\n# rsconnect note\r\n-e ./libs/local\r\n" + + +def test_requirements_file_rejects_editable_outside_bundle(tmp_path: Path): + (tmp_path / "requirements.txt").write_text("six\n-e /Users/me/localpkg\n") + + with pytest.raises( + RSConnectException, + match="Editable dependencies outside the deployment directory cannot be installed on Connect", + ) as error: + Environment.create_python_environment(str(tmp_path)) + + assert "-e /Users/me/localpkg" in str(error.value) def test_uv_lock_export(tmp_path): From 3c407253a4e9f874a32ad9d7d31165d634c5c42c Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 29 Oct 2026 13:51:59 -0400 Subject: [PATCH 6/9] fix: satisfy pyright for optional requirements contents Co-Authored-By: Claude Opus 5.5 --- rsconnect/subprocesses/inspect_environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 939dc697..5bb0585d 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -103,7 +103,7 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require ) if result is not None: - check_editables_are_bundleable(result["contents"], dirname) + check_editables_are_bundleable(result["contents"] or "", dirname) result["python"] = get_python_version() result["pip"] = get_version("pip") result["locale"] = get_default_locale() From e89efee64443be870fd512878d2805874f176e89 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 4 Nov 2026 13:52:36 -0500 Subject: [PATCH 7/9] fix: exclude both rsconnect distribution names --- rsconnect/subprocesses/inspect_environment.py | 5 +++-- tests/test_environment.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 5bb0585d..64593116 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -36,6 +36,7 @@ version_re = re.compile(r"\d+\.\d+(\.\d+)?") exec_dir = os.path.dirname(sys.executable) rsconnect_project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +excluded_distributions = {"rsconnect", "rsconnect-python"} @dataclass(frozen=True) @@ -345,7 +346,7 @@ def is_rsconnect_requirement(line: str) -> bool: requirement = Requirement(line.strip()) except InvalidRequirement: return False - return canonicalize_name(requirement.name) == "rsconnect-python" + return canonicalize_name(requirement.name) in excluded_distributions def editable_target(line: str) -> Optional[str]: @@ -363,7 +364,7 @@ def is_rsconnect_editable(target: str) -> bool: # pip freeze names VCS editables with an #egg= fragment; local checkouts are only identifiable by path. egg = parse_qs(url.fragment).get("egg") if egg: - return canonicalize_name(egg[0]) == "rsconnect-python" + return canonicalize_name(egg[0]) in excluded_distributions return is_path(target) and os.path.isdir(target) and os.path.samefile(target, rsconnect_project_dir) diff --git a/tests/test_environment.py b/tests/test_environment.py index 8aed27a5..0fa3c074 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -84,7 +84,7 @@ def test_requirements_override(self): ) assert result.filename.endswith("custom.txt") - assert result.contents == "foo==1.0\nbar>=2.0\nrsconnect==0.1\n" + assert result.contents == "foo==1.0\nbar>=2.0\n" assert result.source == "file" def test_pip_freeze(self): @@ -139,12 +139,15 @@ def test_filter_generated_requirements(): raw = "\n".join( [ "rsconnect-python==1.0", + "rsconnect==1.0", + "RSCONNECT @ https://example.com/rsconnect.whl", "RSCONNECT_PYTHON @ https://example.com/rsconnect.whl", "rsconnect.python>=1", "rsconnect-python", " Rsconnect_-Python[extra] ; python_version >= '3.8'", "-rsconnect-python", "my-rsconnect-utils==0.1", + "rsconnect-utils==0.1", "rsconnect-python-extra==1.0", "demo @ file:///work/rsconnect-demo", "# Editable install with no version control (demo==1.0)", @@ -162,6 +165,7 @@ def test_filter_generated_requirements(): assert filter_pip_freeze_output(raw).splitlines() == [ "-rsconnect-python", "my-rsconnect-utils==0.1", + "rsconnect-utils==0.1", "rsconnect-python-extra==1.0", "demo @ file:///work/rsconnect-demo", "-e git+https://example.com/repo.git#egg=remote", @@ -217,12 +221,16 @@ def test_filter_generated_requirements_drops_editable_rsconnect(): [ "six==1.17.0", "-e git+https://github.com/posit-dev/rsconnect-python.git@abc123#egg=rsconnect_python", + "-e git+https://example.com/rsconnect.git#egg=RSCONNECT", + "-e git+https://example.com/rsconnect-utils.git#egg=rsconnect-utils", "# Editable install with no version control (rsconnect-python==1.0)", f"-e {rsconnect_project_dir}", ] ) - assert filter_pip_freeze_output(raw) == "six==1.17.0" + assert filter_pip_freeze_output(raw) == ( + "six==1.17.0\n-e git+https://example.com/rsconnect-utils.git#egg=rsconnect-utils" + ) def test_check_editables_reports_all_unbundleable_editables(tmp_path: Path): @@ -254,7 +262,7 @@ def test_inspect_environment_reports_local_editable(tmp_path: Path, monkeypatch) def test_requirements_file_filters_only_rsconnect_distribution(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes( - b"RSCONNECT_PYTHON==1.0\r\nmy-rsconnect-utils==0.1\r\n" + b"RSCONNECT_PYTHON==1.0\r\nrsconnect==1.0\r\nmy-rsconnect-utils==0.1\r\n" b"demo @ file:///work/rsconnect-demo\r\n# rsconnect note\r\n-e ./libs/local\r\n" ) From e9b459279a53d91db3538ff96248a266d1f6e015 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 4 Nov 2026 14:12:13 -0500 Subject: [PATCH 8/9] fix: require pip 20.3 for dependency inspection --- docs/CHANGELOG.md | 5 + docs/deploying.md | 7 + pyproject.toml | 2 +- rsconnect/subprocesses/inspect_environment.py | 101 ++++++++++- tests/test_environment.py | 171 +++++++++++++++++- tests/test_pip_compatibility.py | 71 ++++++++ uv.lock | 2 +- 7 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 tests/test_pip_compatibility.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ba110352..ec678ad0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Generating requirements from an environment now requires pip 20.3 or later. + Older environments can upgrade pip or provide a requirements file. +- Reports editable dependencies outside the deployment directory, which Posit Connect + cannot install, in both generated and user-provided requirements. Requirement + filtering no longer drops unrelated packages whose name or path contains `rsconnect`. - `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a Posit Connect server, so other tools, such as the Connect SDKs, can make API requests with your login. `--refresh` gets a new token after the server rejects diff --git a/docs/deploying.md b/docs/deploying.md index 7e5291ad..d8bc88f3 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -35,6 +35,13 @@ ensuring that you use the same Python that you use to run your Jupyter Notebook: /path/to/python -m pip list --format=freeze ``` +Generating requirements from an environment requires pip 20.3 or later in the +selected Python environment. Pip 20.3 introduced named `pip freeze --exclude` +options and supports Python 3.8, the oldest Python version supported by +rsconnect-python. Upgrade pip in older environments or supply a requirements +file. Newer Python versions may need a later pip release. This requirement +applies to inspection, not the installer used by Posit Connect. + #### Python Version When deploying Python content to Posit Connect, diff --git a/pyproject.toml b/pyproject.toml index 9a3b9ac2..e304b537 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ version = "1.32.1.dev1" dependencies = [ "typing-extensions>=4.8.0", - "pip>=10.0.0", + "pip>=20.3", "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0; python_version < '3.9'", diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index b24bf851..b21b3642 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -13,13 +13,20 @@ import datetime import json import locale +import ntpath import os +import posixpath import tempfile import re import subprocess import sys from dataclasses import asdict, dataclass, replace from typing import Callable, List, Optional, cast +from urllib.parse import parse_qs, urlsplit + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version try: import tomllib @@ -29,6 +36,8 @@ version_re = re.compile(r"\d+\.\d+(\.\d+)?") exec_dir = os.path.dirname(sys.executable) +rsconnect_project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +excluded_distributions = {"rsconnect", "rsconnect-python"} @dataclass(frozen=True) @@ -96,6 +105,7 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require ) if result is not None: + check_editables_are_bundleable(result["contents"] or "", dirname) result["python"] = get_python_version() result["pip"] = get_version("pip") result["locale"] = get_default_locale() @@ -152,7 +162,7 @@ def output_file(dirname: str, filename: str, package_manager: str): with open(path, "r", newline="") as f: data = f.read() - data = "\n".join([line for line in data.split("\n") if "rsconnect" not in line]) + data = "\n".join(line for line in data.split("\n") if not is_rsconnect_requirement(line)) return { "filename": filename, @@ -165,15 +175,33 @@ def output_file(dirname: str, filename: str, package_manager: str): def pip_freeze(): - """Inspect the environment using `pip freeze --disable-pip-version-check version`. + """Inspect the environment using `pip freeze`. Returns a dictionary containing the filename (always 'requirements.txt') and contents if successful, or a dictionary containing 'error' on failure. """ + pip_version = get_version("pip") + if Version(pip_version) < Version("20.3"): + raise EnvironmentException( + f"Generating requirements requires pip 20.3 or later (found {pip_version}). " + f"Upgrade pip with '{sys.executable} -m pip install --upgrade pip', " + "or provide a requirements file." + ) + try: proc = subprocess.Popen( - [sys.executable, "-m", "pip", "freeze", "--disable-pip-version-check"], + [ + sys.executable, + "-m", + "pip", + "freeze", + "--disable-pip-version-check", + "--exclude", + "rsconnect-python", + "--exclude", + "rsconnect", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, @@ -309,12 +337,75 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): def filter_pip_freeze_output(pip_stdout: str): - # Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]` return "\n".join( - [line for line in pip_stdout.split("\n") if (("rsconnect" not in line) and (line.find("[notice]") != 0))] + line + for line in pip_stdout.split("\n") + if not is_rsconnect_requirement(line) + and not line.startswith("[notice]") + and not line.startswith("# Editable install with no version control (") ) +def check_editables_are_bundleable(requirements: str, dirname: str): + unbundleable = [line.strip() for line in requirements.split("\n") if is_unbundleable_editable(line, dirname)] + if unbundleable: + raise EnvironmentException( + "Editable dependencies outside the deployment directory cannot be installed on Connect:\n" + + "\n".join(unbundleable) + + "\nProvide a requirements file that installs them from a package index, a VCS URL, " + "or a relative path inside the deployment directory." + ) + + +def is_rsconnect_requirement(line: str) -> bool: + target = editable_target(line) + if target is not None: + return is_rsconnect_editable(target) + try: + requirement = Requirement(line.strip()) + except InvalidRequirement: + return False + return canonicalize_name(requirement.name) in excluded_distributions + + +def editable_target(line: str) -> Optional[str]: + line = line.strip() + if line.startswith("--editable="): + return line[len("--editable=") :].strip() + for flag in ("-e", "--editable"): + if line.startswith(flag) and line[len(flag) : len(flag) + 1].isspace(): + return line[len(flag) :].strip() + return None + + +def is_rsconnect_editable(target: str) -> bool: + url = urlsplit(target) + # pip freeze names VCS editables with an #egg= fragment; local checkouts are only identifiable by path. + egg = parse_qs(url.fragment).get("egg") + if egg: + return canonicalize_name(egg[0]) in excluded_distributions + return is_path(target) and os.path.isdir(target) and os.path.samefile(target, rsconnect_project_dir) + + +def is_unbundleable_editable(line: str, dirname: str) -> bool: + target = editable_target(line) + if not target: + return False + 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 + + +def is_path(target: str) -> bool: + # PEP 508 URLs use RFC 3986 schemes; a one-letter scheme is a Windows drive, so the target is a path. + return len(urlsplit(target).scheme) <= 1 + + def strip_ref(line: str): # remove erroneous conda build paths that will break pip install # Keep a CRLF line ending. The caller splits on "\n", so the "\r" stays on the line. diff --git a/tests/test_environment.py b/tests/test_environment.py index ff841b64..d1d03ec2 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -14,7 +14,9 @@ from rsconnect.subprocesses.inspect_environment import ( EnvironmentException, detect_environment, + check_editables_are_bundleable, filter_pip_freeze_output, + rsconnect_project_dir, get_default_locale, get_python_version, ) @@ -75,7 +77,7 @@ def test_requirements_override(self): os.makedirs(os.path.join(project_dir, "alt"), exist_ok=True) custom_requirements = os.path.join(project_dir, "alt", "custom.txt") with open(custom_requirements, "w", newline="\n") as f: - f.write("foo==1.0\nbar>=2.0\nrsconnect==0.1\n") + f.write("foo==1.0\nbar>=2.0\nrsconnect-python==0.1\nrsconnect==0.1\n") result = Environment.create_python_environment( project_dir, requirements_file=os.path.join("alt", "custom.txt") @@ -133,6 +135,173 @@ def test_filter_pip_freeze_output(self): self.assertEqual(filtered, expected) +def test_filter_generated_requirements(): + raw = "\n".join( + [ + "rsconnect-python==1.0", + "rsconnect==1.0", + "RSCONNECT @ https://example.com/rsconnect.whl", + "RSCONNECT_PYTHON @ https://example.com/rsconnect.whl", + "rsconnect.python>=1", + "rsconnect-python", + " Rsconnect_-Python[extra] ; python_version >= '3.8'", + "-rsconnect-python", + "my-rsconnect-utils==0.1", + "rsconnect-utils==0.1", + "rsconnect-python-extra==1.0", + "demo @ file:///work/rsconnect-demo", + "# Editable install with no version control (demo==1.0)", + "-e git+https://example.com/repo.git#egg=remote", + "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "-e bzr+lp:project#egg=remote-bzr", + "-e git+https:repo#egg=remote-git", + "-e hg+http://example.com/repo#egg=remote-hg", + "--editable=svn+svn://example.com/repo#egg=remote-svn", + "-e https://example.com/project.tar.gz#egg=remote-archive", + "# my rsconnect project", + ] + ) + + assert filter_pip_freeze_output(raw).splitlines() == [ + "-rsconnect-python", + "my-rsconnect-utils==0.1", + "rsconnect-utils==0.1", + "rsconnect-python-extra==1.0", + "demo @ file:///work/rsconnect-demo", + "-e git+https://example.com/repo.git#egg=remote", + "-e git+ssh://git@example.com/repo.git#egg=remote-ssh", + "-e bzr+lp:project#egg=remote-bzr", + "-e git+https:repo#egg=remote-git", + "-e hg+http://example.com/repo#egg=remote-hg", + "--editable=svn+svn://example.com/repo#egg=remote-svn", + "-e https://example.com/project.tar.gz#egg=remote-archive", + "# my rsconnect project", + ] + + +@pytest.mark.parametrize( + "requirement", + [ + "-e /work/rsconnect-demo", + "-e ../outside-project", + "-e ./libs/../../outside-project", + "--editable=file:///work/project", + "-e git+file:///work/repo#egg=project", + "-e git+file:/work/repo#egg=project", + "-e C:\\work\\project", + "-e D:/work/project", + "-e FILE:///work/project", + ], +) +def test_check_editables_rejects_editables_outside_bundle(tmp_path: Path, requirement): + with pytest.raises(EnvironmentException) as error: + check_editables_are_bundleable(f"six==1.17.0\n{requirement}\n", str(tmp_path)) + + assert requirement in str(error.value) + assert "requirements file" in str(error.value) + + +@pytest.mark.parametrize( + "requirement", + [ + "-e ./libs/foo", + "-e libs/foo", + "--editable=.", + "-e git+https://example.com/repo.git#egg=remote", + "-e bzr+lp:project#egg=remote-bzr", + "six @ file:///work/six", + ], +) +def test_check_editables_allows_bundleable_requirements(tmp_path: Path, requirement): + check_editables_are_bundleable(f"{requirement}\r\n", str(tmp_path)) + + +def test_filter_generated_requirements_drops_editable_rsconnect(): + raw = "\n".join( + [ + "six==1.17.0", + "-e git+https://github.com/posit-dev/rsconnect-python.git@abc123#egg=rsconnect_python", + "-e git+https://example.com/rsconnect.git#egg=RSCONNECT", + "-e git+https://example.com/rsconnect-utils.git#egg=rsconnect-utils", + "# Editable install with no version control (rsconnect-python==1.0)", + f"-e {rsconnect_project_dir}", + ] + ) + + assert filter_pip_freeze_output(raw) == ( + "six==1.17.0\n-e git+https://example.com/rsconnect-utils.git#egg=rsconnect-utils" + ) + + +def test_check_editables_reports_all_unbundleable_editables(tmp_path: Path): + with pytest.raises(EnvironmentException) as error: + check_editables_are_bundleable("-e /work/one\n-e /work/two", str(tmp_path)) + + assert "-e /work/one" in str(error.value) + assert "-e /work/two" in str(error.value) + + +def test_inspect_environment_reports_local_editable(tmp_path: Path, monkeypatch): + pip_dir = tmp_path / "pip" + pip_dir.mkdir() + (pip_dir / "__init__.py").write_text("") + (pip_dir / "__main__.py").write_text( + "import sys\n" + "if '--version' in sys.argv:\n" + " print('pip 20.3')\n" + "else:\n" + " print('# Editable install with no version control (demo==1.0)')\n" + " print('-e /work/demo')\n" + ) + monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(tmp_path), str(Path(__file__).resolve().parents[1])])) + + with pytest.raises( + RSConnectException, + match="Editable dependencies outside the deployment directory cannot be installed on Connect", + ) as error: + Environment._inspect_environment(sys.executable, str(tmp_path), requirements_file=None) + + assert "-e /work/demo" in str(error.value) + assert "requirements file" in str(error.value) + + +def test_inspect_environment_rejects_old_pip_for_generated_requirements(tmp_path: Path, monkeypatch): + pip_dir = tmp_path / "pip" + pip_dir.mkdir() + (pip_dir / "__init__.py").write_text("") + (pip_dir / "__main__.py").write_text("print('pip 20.2.4')\n") + monkeypatch.setenv("PYTHONPATH", os.pathsep.join([str(tmp_path), str(Path(__file__).resolve().parents[1])])) + + with pytest.raises(RSConnectException, match=r"requires pip 20\.3 or later \(found 20\.2\.4\)") as error: + Environment._inspect_environment(sys.executable, str(tmp_path), requirements_file=None) + + assert "install --upgrade pip" in str(error.value) + assert "requirements file" in str(error.value) + + +def test_requirements_file_filters_only_rsconnect_distribution(tmp_path: Path): + (tmp_path / "requirements.txt").write_bytes( + b"RSCONNECT_PYTHON==1.0\r\nrsconnect==1.0\r\nmy-rsconnect-utils==0.1\r\n" + b"demo @ file:///work/rsconnect-demo\r\n# rsconnect note\r\n-e ./libs/local\r\n" + ) + + env = Environment.create_python_environment(str(tmp_path)) + + assert env.contents == "my-rsconnect-utils==0.1\r\ndemo\r\n# rsconnect note\r\n-e ./libs/local\r\n" + + +def test_requirements_file_rejects_editable_outside_bundle(tmp_path: Path): + (tmp_path / "requirements.txt").write_text("six\n-e /Users/me/localpkg\n") + + with pytest.raises( + RSConnectException, + match="Editable dependencies outside the deployment directory cannot be installed on Connect", + ) as error: + Environment.create_python_environment(str(tmp_path)) + + assert "-e /Users/me/localpkg" in str(error.value) + + def test_uv_lock_export(tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() diff --git a/tests/test_pip_compatibility.py b/tests/test_pip_compatibility.py new file mode 100644 index 00000000..6aa03a44 --- /dev/null +++ b/tests/test_pip_compatibility.py @@ -0,0 +1,71 @@ +"""Opt-in integration checks against an isolated Python with pip installed.""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest +from packaging.version import Version + + +@pytest.mark.skipif(not os.environ.get("RSCONNECT_COMPAT_PYTHON"), reason="No compatibility interpreter selected") +def test_dependency_inspection_with_selected_pip(tmp_path: Path): + python = os.path.abspath(os.environ["RSCONNECT_COMPAT_PYTHON"]) + repo = Path(__file__).resolve().parents[1] + site = tmp_path / "site" + site.mkdir() + for name in ("rsconnect", "rsconnect-python", "rsconnect-utils", "demo-url"): + dist = site / f"{name.replace('-', '_')}-1.0.dist-info" + dist.mkdir() + (dist / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {name}\nVersion: 1.0\n") + if name == "demo-url": + (dist / "direct_url.json").write_text( + json.dumps({"url": "https://example.com/rsconnect/demo.whl", "archive_info": {}}) + ) + + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join((str(repo), str(site))) + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + + def inspect(requirements_file): + result = subprocess.run( + [python, "-m", "rsconnect.subprocesses.inspect_environment", "-r", requirements_file, str(tmp_path)], + capture_output=True, + text=True, + check=True, + cwd=tmp_path, + env=env, + ) + return json.loads(result.stdout) + + version = subprocess.check_output([python, "-m", "pip", "--version"], text=True).split()[1] + generated = inspect("none") + if Version(version) < Version("20.3"): + assert f"requires pip 20.3 or later (found {version})" in generated["error"] + assert "install --upgrade pip" in generated["error"] + else: + lines = generated["contents"].splitlines() + assert "rsconnect-utils==1.0" in lines + assert "demo-url @ https://example.com/rsconnect/demo.whl" in lines + assert "rsconnect==1.0" not in lines + assert "rsconnect-python==1.0" not in lines + + local = tmp_path / "rsconnect-local" + metadata = local / "demo_local.egg-info" + metadata.mkdir(parents=True) + (metadata / "PKG-INFO").write_text("Metadata-Version: 2.1\nName: demo-local\nVersion: 1.0\n") + (site / "demo-local.egg-link").write_text(f"{local}\n.\n") + env["PYTHONPATH"] += os.pathsep + str(local) + rejected = inspect("none") + assert "Editable dependencies outside the deployment directory" in rejected["error"] + assert str(local.resolve()) in rejected["error"] + + (tmp_path / "requirements.txt").write_text( + "rsconnect==1.0\nrsconnect-python==1.0\nrsconnect-utils==1.0\n" + "demo-url @ https://example.com/rsconnect/demo.whl\n-e ./rsconnect-local\n" + ) + requirements = inspect("requirements.txt") + assert requirements["contents"] == ( + "rsconnect-utils==1.0\ndemo-url @ https://example.com/rsconnect/demo.whl\n-e ./rsconnect-local\n" + ) diff --git a/uv.lock b/uv.lock index 109da124..69fad309 100644 --- a/uv.lock +++ b/uv.lock @@ -5532,7 +5532,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0" }, { name = "keyring", marker = "extra == 'keyring'", specifier = ">=23.0.0" }, { name = "packaging", specifier = ">=20.0" }, - { name = "pip", specifier = ">=10.0.0" }, + { name = "pip", specifier = ">=20.3" }, { name = "pyjwt", marker = "python_full_version < '3.9'", specifier = ">=2.4.0" }, { name = "pyjwt", marker = "python_full_version >= '3.9'", specifier = ">=2.15.0" }, { name = "semver", specifier = ">=2.0.0,<4.0.0" }, From 019dbd37bf2dc5c6f11563f68cf43fdc06d2f703 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Thu, 5 Nov 2026 11:48:32 -0500 Subject: [PATCH 9/9] refactor: derive pip freeze exclusions from excluded_distributions Co-Authored-By: Claude Opus 5.5 --- rsconnect/subprocesses/inspect_environment.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index b21b3642..69354364 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -197,10 +197,7 @@ def pip_freeze(): "pip", "freeze", "--disable-pip-version-check", - "--exclude", - "rsconnect-python", - "--exclude", - "rsconnect", + *(arg for name in sorted(excluded_distributions) for arg in ("--exclude", name)), ], stdout=subprocess.PIPE, stderr=subprocess.PIPE,