Skip to content
Draft
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
16 changes: 8 additions & 8 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down Expand Up @@ -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."
),
Expand Down
49 changes: 49 additions & 0 deletions rsconnect/subprocesses/inspect_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading