From cf4a5fa556d36a18d743b1c7c9035493582e7dae Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 11:58:04 -0400 Subject: [PATCH 1/2] fix: validate pyproject dependency entries --- rsconnect/subprocesses/inspect_environment.py | 8 ++++--- tests/test_environment.py | 23 ++++++++++++++++++- tests/test_main.py | 15 ++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index ab0f598a..319a660f 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -19,7 +19,7 @@ import subprocess import sys from dataclasses import asdict, dataclass, replace -from typing import Callable, Optional +from typing import Callable, Optional, cast try: import tomllib @@ -288,10 +288,12 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): project = pyproject.get("project", {}) dependencies: list[object] = project.get("dependencies", []) - if not isinstance(dependencies, list): + if not isinstance(dependencies, list) or not all(isinstance(dep, str) for dep in dependencies): raise EnvironmentException(f"Invalid project.dependencies in {pyproject_filename}: expected a list of strings.") - requirements = filter_pip_freeze_output("\n".join(str(dep) for dep in dependencies)) + requirements = filter_pip_freeze_output("\n".join(cast(list[str], dependencies))) + if requirements and not requirements.endswith("\n"): + requirements += "\n" requirements = ( f"# requirements.txt generated from pyproject.toml by rsconnect-python on " f"{datetime.datetime.now(datetime.timezone.utc)}\n" diff --git a/tests/test_environment.py b/tests/test_environment.py index cbf364b9..3e91766e 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -230,11 +230,32 @@ def test_pyproject_dependencies(tmp_path): assert "annotated-types>=0.7.0" in env.contents # rsconnect dependency lines must be stripped, matching the behavior of output_file() and pip_freeze() dep_lines = [line for line in env.contents.splitlines() if line and not line.startswith("#")] - assert not any("rsconnect" in line for line in dep_lines) + assert dep_lines == ["aiofiles==24.1.0", "annotated-types>=0.7.0"] + assert env.contents.endswith("\n") assert env.source == "pyproject_toml" assert env.package_manager == "pip" +@pytest.mark.parametrize("dependencies", ["3", "['flask', 3]", "['flask', {a = 'b'}]", "[true]", "[['flask']]"]) +def test_pyproject_dependencies_invalid(tmp_path, dependencies): + (tmp_path / "pyproject.toml").write_text(f"[project]\ndependencies = {dependencies}\n", encoding="utf-8") + + with pytest.raises( + EnvironmentException, match=r"Invalid project\.dependencies in pyproject\.toml: expected a list of strings\." + ): + detect_environment(str(tmp_path), requirements_file="pyproject.toml") + + +@pytest.mark.parametrize("dependencies", ["[]", "['rsconnect-python']"]) +def test_pyproject_dependencies_empty(tmp_path, dependencies): + (tmp_path / "pyproject.toml").write_text(f"[project]\ndependencies = {dependencies}\n", encoding="utf-8") + + env = detect_environment(str(tmp_path), requirements_file="pyproject.toml") + + assert len(env.contents.splitlines()) == 1 + assert env.contents.endswith("\n") + + def test_requirements_file_keeps_crlf_line_endings(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes(b"numpy\r\npandas\r\n") diff --git a/tests/test_main.py b/tests/test_main.py index 94a59c2f..d7874d7c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2182,6 +2182,21 @@ def test_explicit_requirements_file(self, tmp_path): assert requirements.read_text() == "example-package==1.0\n" assert manifest["files"][requirements.name]["checksum"] == file_checksum(requirements) + @pytest.mark.parametrize("dependencies", ["['flask', 3, {a = 'b'}]", "['flask']"]) + def test_pyproject_requirements_file(self, tmp_path, dependencies): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / "pyproject.toml").write_text(f"[project]\ndependencies = {dependencies}\n") + + result = CliRunner().invoke(cli, ["write-manifest", "api", "-r", "pyproject.toml", str(tmp_path)]) + requirements = tmp_path / "requirements.txt" + if dependencies == "['flask']": + assert result.exit_code == 0, result.output + assert requirements.read_text().endswith("\nflask\n") + else: + assert result.exit_code != 0 + assert "Invalid project.dependencies in pyproject.toml: expected a list of strings" in result.output + assert not requirements.exists() + class TestWriteManifestExcludeRenv: def test_exclude_renv_omits_r_dependencies(self, tmp_path): From 9c49d47f6691547abb3243300b67ac0920a654e2 Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 13:08:03 -0400 Subject: [PATCH 2/2] fix: maintain Python 3.8 dependency inspection compatibility --- rsconnect/subprocesses/inspect_environment.py | 6 +++--- tests/test_environment.py | 19 ++++++++++++++++++- tests/test_main.py | 15 ++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 319a660f..db4e13fa 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -19,7 +19,7 @@ import subprocess import sys from dataclasses import asdict, dataclass, replace -from typing import Callable, Optional, cast +from typing import Callable, List, Optional, cast try: import tomllib @@ -287,11 +287,11 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): raise EnvironmentException(f"Error reading {pyproject_filename}: {exception}") project = pyproject.get("project", {}) - dependencies: list[object] = project.get("dependencies", []) + dependencies: List[object] = project.get("dependencies", []) if not isinstance(dependencies, list) or not all(isinstance(dep, str) for dep in dependencies): raise EnvironmentException(f"Invalid project.dependencies in {pyproject_filename}: expected a list of strings.") - requirements = filter_pip_freeze_output("\n".join(cast(list[str], dependencies))) + requirements = filter_pip_freeze_output("\n".join(cast(List[str], dependencies))) if requirements and not requirements.endswith("\n"): requirements += "\n" requirements = ( diff --git a/tests/test_environment.py b/tests/test_environment.py index 3e91766e..7dc94c50 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -236,7 +236,24 @@ def test_pyproject_dependencies(tmp_path): assert env.package_manager == "pip" -@pytest.mark.parametrize("dependencies", ["3", "['flask', 3]", "['flask', {a = 'b'}]", "[true]", "[['flask']]"]) +@pytest.mark.parametrize( + "dependencies", + [ + "3", + "[3]", + "[{a = 'b'}]", + pytest.param( + "['flask', 3]", + marks=pytest.mark.skipif(sys.version_info < (3, 11), reason="toml fallback rejects heterogeneous arrays"), + ), + pytest.param( + "['flask', {a = 'b'}]", + marks=pytest.mark.skipif(sys.version_info < (3, 11), reason="toml fallback rejects heterogeneous arrays"), + ), + "[true]", + "[['flask']]", + ], +) def test_pyproject_dependencies_invalid(tmp_path, dependencies): (tmp_path / "pyproject.toml").write_text(f"[project]\ndependencies = {dependencies}\n", encoding="utf-8") diff --git a/tests/test_main.py b/tests/test_main.py index d7874d7c..75623ef9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2182,7 +2182,20 @@ def test_explicit_requirements_file(self, tmp_path): assert requirements.read_text() == "example-package==1.0\n" assert manifest["files"][requirements.name]["checksum"] == file_checksum(requirements) - @pytest.mark.parametrize("dependencies", ["['flask', 3, {a = 'b'}]", "['flask']"]) + @pytest.mark.parametrize( + "dependencies", + [ + pytest.param( + "['flask', 3, {a = 'b'}]", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), reason="toml fallback rejects heterogeneous arrays" + ), + ), + "[3]", + "[{a = 'b'}]", + "['flask']", + ], + ) def test_pyproject_requirements_file(self, tmp_path, dependencies): (tmp_path / "app.py").write_text("app = object()\n") (tmp_path / "pyproject.toml").write_text(f"[project]\ndependencies = {dependencies}\n")