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
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- Getting-started documentation recommends `rsconnect login` for Posit Connect,
with API keys as an alternative.
- `rsconnect token` prints the OAuth access token stored by `rsconnect login` for a
Expand Down
7 changes: 7 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
98 changes: 93 additions & 5 deletions rsconnect/subprocesses/inspect_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the inspector runs in the users venv so putting things outside of the standard library here could crash the inspection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to replace the packaging calls with standard library code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 #889 removes this constraint. The inspector will run on the CLI's own interpreter, where packaging is always installed, and receive the project's interpreter as --python. The project's interpreter only gets asked for three things, and none of them imports rsconnect or its dependencies:

  • its Python version: <python> -I -c "import sys; print(...)"
  • its pip version: <python> -m pip --version (optional; we fall back when pip is missing)
  • its installed packages: <python> -m pip freeze, or uv pip freeze --python <python>

I ran all three in a venv with neither rsconnect nor packaging, and they work. That also fixes the related uv tool install problem, where the project's venv doesn't have rsconnect at all.

Until #889 lands, the risk from this PR is small. The inspector runs as <python> -m rsconnect..., so rsconnect must already be installed there. packaging is a declared dependency of the released 1.32.0, so a normal install already brings it along. The only setups that newly break are rsconnect importable without its dependencies (--no-deps, PYTHONPATH, a source checkout) on Python ≥ 3.11. On Python ≤ 3.10 the inspector already fails there on toml. Given that, I'd rather keep packaging than write stdlib code to parse requirements.

from packaging.utils import canonicalize_name
from packaging.version import Version

try:
import tomllib
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -165,15 +175,30 @@ 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",
*(arg for name in sorted(excluded_distributions) for arg in ("--exclude", name)),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
Expand Down Expand Up @@ -309,12 +334,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.
Expand Down
171 changes: 170 additions & 1 deletion tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading