From 54aa1a101e5972a8f53b623b0269bbcde330ff81 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 29 Apr 2026 17:48:15 +0200 Subject: [PATCH] feat: support dependencies from pyproject.toml --- rsconnect/main.py | 16 +++--- rsconnect/subprocesses/inspect_environment.py | 49 +++++++++++++++++++ tests/test_environment.py | 48 ++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index b2dc0dcc..acb82939 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1106,7 +1106,7 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): default="requirements.txt", help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -1287,7 +1287,7 @@ def deploy_notebook( default="requirements.txt", help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -1544,7 +1544,7 @@ def deploy_manifest( default="requirements.txt", help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -1974,7 +1974,7 @@ def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc type=click.Path(dir_okay=False), help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -2341,7 +2341,7 @@ def write_manifest(): type=click.Path(dir_okay=False), help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -2460,7 +2460,7 @@ def write_manifest_notebook( type=click.Path(exists=True, dir_okay=False), help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -2615,7 +2615,7 @@ def write_manifest_voila( type=click.Path(dir_okay=False), help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), @@ -2832,7 +2832,7 @@ def generate_write_manifest_python(app_mode: AppMode, alias: str, desc: Optional type=click.Path(dir_okay=False), help=( "Path to requirements file listing the project dependencies. " - "Any file compatible with requirements.txt format or uv.lock is accepted, " + "Any file compatible with requirements.txt format, uv.lock, or pyproject.toml is accepted, " "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index ce18ae72..2ce1e664 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -20,6 +20,12 @@ from dataclasses import asdict, dataclass, replace from typing import Callable, Optional +try: + import tomllib +except ImportError: + # Python <3.11 doesn't have tomllib in the standard library + import toml as tomllib # type: ignore[no-redef] + version_re = re.compile(r"\d+\.\d+(\.\d+)?") exec_dir = os.path.dirname(sys.executable) @@ -78,6 +84,8 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require result = pip_freeze() elif os.path.basename(requirements_file) == "uv.lock": result = uv_export(dirname, requirements_file) + elif os.path.basename(requirements_file) == "pyproject.toml": + result = pyproject_dependencies(dirname, requirements_file) else: result = output_file(dirname, requirements_file, "pip") if result is None: @@ -255,6 +263,47 @@ def uv_export(dirname: str, lock_filename: str): } +def pyproject_dependencies(dirname: str, pyproject_filename: str): + """Read project.dependencies from a pyproject.toml file as a requirements spec. + + Emits a requirements.txt file listing the top-level dependencies declared + in ``[project].dependencies``. Unlike ``uv.lock``, the result is not a + fully resolved lock and Connect will perform dependency resolution at + deploy time. + """ + pyproject_path = pyproject_filename + if not os.path.isabs(pyproject_filename): + pyproject_path = os.path.join(dirname, pyproject_filename) + + if not os.path.exists(pyproject_path): + raise EnvironmentException(f"pyproject.toml not found: {pyproject_filename}") + + try: + with open(pyproject_path, "r", encoding="utf-8") as f: + pyproject = tomllib.loads(f.read()) + except Exception as exception: + raise EnvironmentException(f"Error reading {pyproject_filename}: {exception}") + + project = pyproject.get("project", {}) + dependencies: list[object] = project.get("dependencies", []) + if not isinstance(dependencies, list): + 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 = ( + f"# requirements.txt generated from pyproject.toml by rsconnect-python on " + f"{datetime.datetime.now(datetime.timezone.utc)}\n" + f"{requirements}" + ) + + return { + "filename": "requirements.txt", + "contents": requirements, + "source": "pyproject_toml", + "package_manager": "pip", + } + + 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( diff --git a/tests/test_environment.py b/tests/test_environment.py index fcecb63f..f22bf69c 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -194,6 +194,54 @@ def test_uv_lock_export(tmp_path): assert env.package_manager == "uv" +def test_pyproject_dependencies(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "pyproject.toml").write_text( + "[project]\n" + "name = 'demo'\n" + "version = '0.0.0'\n" + "dependencies = [\n" + " 'aiofiles==24.1.0',\n" + " 'annotated-types>=0.7.0',\n" + " 'rsconnect-python==1.0.0',\n" + "]\n", + encoding="utf-8", + ) + + env = detect_environment(str(project_dir), requirements_file="pyproject.toml") + + assert env.filename == "requirements.txt" + assert "aiofiles==24.1.0" in env.contents + 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 env.source == "pyproject_toml" + assert env.package_manager == "pip" + + +def test_pyproject_dependencies_missing(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + + with pytest.raises(EnvironmentException, match="pyproject.toml not found"): + detect_environment(str(project_dir), requirements_file="pyproject.toml") + + +def test_pyproject_dependencies_no_project_table(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "pyproject.toml").write_text("[build-system]\nrequires = ['setuptools']\n", encoding="utf-8") + + env = detect_environment(str(project_dir), requirements_file="pyproject.toml") + + assert env.filename == "requirements.txt" + assert env.source == "pyproject_toml" + # Header comment is present but no dependencies are listed. + assert all(line.startswith("#") or line == "" for line in env.contents.splitlines()) + + class WhichPythonTestCase(TestCase): def test_default(self): self.assertEqual(which_python(), sys.executable)