From 9b1cd1dbfe996aa1398be99442632e30891f489a Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 11 Jun 2026 16:59:47 +0200 Subject: [PATCH 1/3] add write-manifest pyproject command --- .gitignore | 1 + rsconnect/main.py | 211 +++++++++++- rsconnect/pyproject.py | 62 ++++ tests/test_deploy_pyproject.py | 36 ++ tests/test_write_manifest_pyproject.py | 454 +++++++++++++++++++++++++ 5 files changed, 747 insertions(+), 17 deletions(-) create mode 100644 tests/test_write_manifest_pyproject.py diff --git a/.gitignore b/.gitignore index 892ae5dc..59d71a48 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ coverage.xml # Temporary dev files vetiver-testing/rsconnect_api_keys.json /pip-wheel-metadata/ +.pi # license files should not be commited to this repository *.lic diff --git a/rsconnect/main.py b/rsconnect/main.py index 76abc89e..c3350710 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -137,7 +137,12 @@ VersionSearchFilter, VersionSearchFilterParamType, ) -from .pyproject import InvalidPyprojectConfigError, TOMLDecodeError, read_tool_rsconnect +from .pyproject import ( + InvalidPyprojectConfigError, + TOMLDecodeError, + UnsupportedAppModeError, + resolve_pyproject_deploy_target, +) from .environment import PackageInstaller from .utils_package import fix_starlette_requirements @@ -1836,7 +1841,11 @@ def quickstart_hint() -> str: pyproject_path = Path(directory) / "pyproject.toml" try: - config = read_tool_rsconnect(pyproject_path) + target = resolve_pyproject_deploy_target( + pyproject_path, requirements_file=requirements_file, title_override=title or name + ) + except UnsupportedAppModeError as err: + raise RSConnectException(str(err)) from err except InvalidPyprojectConfigError as err: raise RSConnectException(f"{err}\n\n{quickstart_hint()}") from err except FileNotFoundError as err: @@ -1844,13 +1853,10 @@ def quickstart_hint() -> str: except TOMLDecodeError as err: raise RSConnectException(f"pyproject.toml could not be parsed: {err}\n\n{quickstart_hint()}") from err - configured_app_mode = cast(str, config["app_mode"]) - app_mode = AppModes.get_by_name(configured_app_mode, return_unknown=True) - if app_mode == AppModes.UNKNOWN: - raise RSConnectException(f"Unsupported app_mode '{configured_app_mode}' in [tool.rsconnect]") - - entrypoint = cast(str, config["entrypoint"]) - effective_title = cast(Optional[str], config.get("title") or title or name) + app_mode = target.app_mode + entrypoint = target.entrypoint + effective_title = target.title + requirements_file = target.requirements_file extra_files: tuple[str, ...] = tuple() excludes: tuple[str, ...] = tuple() bundle_builder: Callable[..., Any] @@ -1858,13 +1864,6 @@ def quickstart_hint() -> str: bundle_kwargs: dict[str, Any] = {} path = directory - # Requirements source precedence: ``-r`` flag > ``[tool.rsconnect].requirements_file`` - # > built-in default ``pyproject.toml`` (top-level deps; Connect resolves transitive). - # An explicit default keeps the inspector from falling back to a ``pip freeze`` of the - # caller's interpreter. Malformed TOML values (wrong type, missing file) are surfaced - # by the inspector / file existence check. - requirements_file = requirements_file or config.get("requirements_file") or "pyproject.toml" - if app_mode in (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API): if app_mode == AppModes.PYTHON_SHINY: entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) @@ -1925,7 +1924,7 @@ def quickstart_hint() -> str: bundle_args = (path, extra_files, excludes, app_mode, inspect, environment) bundle_kwargs = {"image": None, "env_management_py": None, "env_management_r": None} else: - raise RSConnectException(f"Unsupported app_mode '{configured_app_mode}' in [tool.rsconnect]") + raise RSConnectException(f"Unsupported app_mode '{target.configured_app_mode}' in [tool.rsconnect]") ce = RSConnectExecutor( ctx=ctx, @@ -3202,6 +3201,184 @@ def write_manifest_quarto( ) +@write_manifest.command( + name="pyproject", + short_help="Create a manifest.json file from a project's pyproject.toml.", + help=( + "Create a manifest.json file for later deployment, for content described by a project's " + "pyproject.toml. The given directory must contain a pyproject.toml with a [tool.rsconnect] " + "table specifying app_mode and entrypoint. This will also create the environment file the " + 'manifest references (e.g. "requirements.txt") if one does not exist. Designed as the ' + "write-manifest partner for projects scaffolded by 'rsconnect quickstart'." + ), + no_args_is_help=True, +) +@click.option("--overwrite", "-o", is_flag=True, help="Overwrite manifest.json, if it exists.") +@click.option( + "--requirements-file", + "-r", + type=click.Path(dir_okay=False), + default=None, + help=( + "Path to the requirements source, relative to the project directory. " + "Overrides ``[tool.rsconnect].requirements_file`` in pyproject.toml; " + "defaults to ``pyproject.toml`` (the project's declared dependencies). " + "Pass ``uv.lock`` for a fully resolved manifest, or any ``requirements.txt``-compatible file." + ), +) +@click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") +@click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) +@cli_exception_handler +@click.pass_context +def write_manifest_pyproject( + ctx: click.Context, + overwrite: bool, + requirements_file: Optional[str], + verbose: int, + directory: str, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + + def quickstart_hint() -> str: + return "To create a new project with this section already populated, run: rsconnect quickstart --help" + + pyproject_path = Path(directory) / "pyproject.toml" + try: + target = resolve_pyproject_deploy_target(pyproject_path, requirements_file=requirements_file) + except UnsupportedAppModeError as err: + raise RSConnectException(str(err)) from err + except InvalidPyprojectConfigError as err: + raise RSConnectException(f"{err}\n\n{quickstart_hint()}") from err + except FileNotFoundError as err: + raise RSConnectException(f"pyproject.toml not found at {pyproject_path}.\n\n{quickstart_hint()}") from err + except TOMLDecodeError as err: + raise RSConnectException(f"pyproject.toml could not be parsed: {err}\n\n{quickstart_hint()}") from err + + app_mode = target.app_mode + entrypoint = target.entrypoint + extra_files: tuple[str, ...] = tuple() + excludes: tuple[str, ...] = tuple() + + api_modes = (AppModes.STREAMLIT_APP, AppModes.PYTHON_SHINY, AppModes.PYTHON_FASTAPI, AppModes.PYTHON_API) + entrypoint_manifest_modes = ( + AppModes.JUPYTER_NOTEBOOK, + AppModes.JUPYTER_VOILA, + AppModes.STATIC_QUARTO, + AppModes.SHINY_QUARTO, + ) + if app_mode not in api_modes and app_mode not in entrypoint_manifest_modes: + raise RSConnectException(f"Unsupported app_mode '{target.configured_app_mode}' in [tool.rsconnect]") + + with cli_feedback("Checking arguments"): + # The bundle.py writers put manifest.json in different places: API modes + # write to the project root, while notebook/voila/quarto write next to + # the entrypoint. Guard the writer's real destination. + if app_mode in entrypoint_manifest_modes: + entry_path = Path(directory) / entrypoint + manifest_dir = entry_path if entry_path.is_dir() else entry_path.parent + else: + manifest_dir = Path(directory) + if (manifest_dir / "manifest.json").exists() and not overwrite: + raise RSConnectException("manifest.json already exists. Use --overwrite to overwrite.") + + def inspect_python_environment() -> Environment: + # Deliberately not resolve_requirements_file: its default is requirements.txt, + # while pyproject projects default to pyproject.toml (deploy parity). + with cli_feedback("Inspecting Python environment"): + return Environment.create_python_environment( + directory, + requirements_file=target.requirements_file, + override_python_version=None, + ) + + environment: Optional[Environment] = None + if app_mode in api_modes: + if app_mode == AppModes.PYTHON_SHINY: + entrypoint = resolve_shiny_express_entrypoint(entrypoint, directory) + environment = inspect_python_environment() + with cli_feedback("Creating manifest.json"): + write_api_manifest_json( + directory, + entrypoint, + environment, + app_mode, + extra_files, + excludes, + image=None, + env_management_py=None, + env_management_r=None, + ) + elif app_mode == AppModes.JUPYTER_NOTEBOOK: # This is "jupyter-static" + environment = inspect_python_environment() + with cli_feedback("Creating manifest.json"): + write_notebook_manifest_json( + str(Path(directory) / entrypoint), + environment, + app_mode, + extra_files, + None, + None, + image=None, + env_management_py=None, + env_management_r=None, + ) + elif app_mode == AppModes.JUPYTER_VOILA: + environment = inspect_python_environment() + with cli_feedback("Creating manifest.json"): + write_voila_manifest_json( + directory, + entrypoint, + environment, + extra_files, + excludes, + True, + image=None, + env_management_py=None, + env_management_r=None, + multi_notebook=False, + ) + elif app_mode in (AppModes.STATIC_QUARTO, AppModes.SHINY_QUARTO): + path = str(Path(directory) / entrypoint) + with cli_feedback("Inspecting Quarto project"): + quarto = which_quarto(None) + logger.debug("Quarto: %s" % quarto) + inspect = quarto_inspect(quarto, path) + engines = validate_quarto_engines(inspect) + + if "jupyter" in engines: + environment = inspect_python_environment() + with cli_feedback("Creating manifest.json"): + # Same target path as deploy (directory/entrypoint), so the manifest + # lands in the project directory next to pyproject.toml. + write_quarto_manifest_json( + path, + inspect, + app_mode, + environment, + extra_files, + excludes, + image=None, + env_management_py=None, + env_management_r=None, + ) + + # The manifest references environment.filename (e.g. a requirements.txt + # generated from pyproject.toml's dependencies), so that file must exist + # next to manifest.json or deploying from the manifest fails. Mirror the + # sibling write-manifest commands: write it, or warn when a file with that + # name already exists. Quarto with non-Jupyter engines has no environment. + if environment is not None: + if (manifest_dir / environment.filename).exists(): + click.secho( + " Warning: %s already exists and will not be overwritten." % environment.filename, + fg="yellow", + ) + else: + with cli_feedback("Creating %s" % environment.filename): + write_environment_file(environment, str(manifest_dir)) + + @write_manifest.command( name="tensorflow", short_help="Create a manifest.json file for TensorFlow content.", diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 57b1bc31..d0b591d7 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -6,12 +6,14 @@ """ import configparser +import dataclasses import pathlib import re import typing from collections.abc import Mapping from .log import logger +from .models import AppMode, AppModes TOMLDecodeError: typing.Type[Exception] try: @@ -171,6 +173,14 @@ class InvalidPyprojectConfigError(ValueError): """Raised when ``[tool.rsconnect]`` is missing or incomplete.""" +class UnsupportedAppModeError(ValueError): + """Raised when ``[tool.rsconnect].app_mode`` names an app mode rsconnect does not know. + + Kept distinct from :class:`InvalidPyprojectConfigError` because the CLI does + not append the quickstart hint for this failure. + """ + + _MINIMUM_VALID_TOOL_RSCONNECT_SNIPPET = """[tool.rsconnect] # e.g. python-streamlit, python-shiny, python-fastapi, jupyter-static, quarto-shiny app_mode = "" @@ -219,3 +229,55 @@ def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, typ ) return tool_rsconnect + + +@dataclasses.dataclass(frozen=True) +class PyprojectDeployTarget: + """Deployment configuration resolved from ``[tool.rsconnect]`` in pyproject.toml.""" + + app_mode: AppMode + # The app_mode string as written in pyproject.toml; may be an alias of + # app_mode.name() and is what error messages should quote back to the user. + configured_app_mode: str + entrypoint: str + requirements_file: str + title: typing.Optional[str] + + +def resolve_pyproject_deploy_target( + pyproject_file: pathlib.Path, + requirements_file: typing.Optional[str] = None, + title_override: typing.Optional[str] = None, +) -> PyprojectDeployTarget: + """Resolve the deployment target described by ``[tool.rsconnect]`` in pyproject.toml. + + Raises ``InvalidPyprojectConfigError`` when the config is missing or + incomplete, and ``UnsupportedAppModeError`` when ``app_mode`` does not name + a known app mode. + + :param pathlib.Path pyproject_file: path to the project's pyproject.toml. + :param typing.Optional[str] requirements_file: caller override for the + requirements source; wins over ``[tool.rsconnect].requirements_file``. + :param typing.Optional[str] title_override: fallback title used when the + config declares none. + """ + config = read_tool_rsconnect(pyproject_file) + + configured_app_mode = typing.cast(str, config["app_mode"]) + app_mode = AppModes.get_by_name(configured_app_mode, return_unknown=True) + if app_mode == AppModes.UNKNOWN: + raise UnsupportedAppModeError(f"Unsupported app_mode '{configured_app_mode}' in [tool.rsconnect]") + + # Requirements source precedence: caller override (the ``-r`` flag) > + # ``[tool.rsconnect].requirements_file`` > built-in default ``pyproject.toml`` + # (top-level deps; Connect resolves transitive). An explicit default keeps the + # inspector from falling back to a ``pip freeze`` of the caller's interpreter. + # Malformed TOML values (wrong type, missing file) are surfaced by the + # inspector / file existence check. + return PyprojectDeployTarget( + app_mode=app_mode, + configured_app_mode=configured_app_mode, + entrypoint=typing.cast(str, config["entrypoint"]), + requirements_file=typing.cast(str, requirements_file or config.get("requirements_file") or "pyproject.toml"), + title=typing.cast(typing.Optional[str], config.get("title")) or title_override, + ) diff --git a/tests/test_deploy_pyproject.py b/tests/test_deploy_pyproject.py index 91246bbb..4a53b639 100644 --- a/tests/test_deploy_pyproject.py +++ b/tests/test_deploy_pyproject.py @@ -361,6 +361,42 @@ def test_deploy_pyproject_dispatches_by_app_mode( pass +@pytest.mark.parametrize( + "app_mode", + ["no-such-mode", "python-dash", "nodejs-api"], + ids=["unknown", "valid-but-unhandled", "aliased"], +) +def test_deploy_pyproject_errors_on_unsupported_app_mode( + runner: CliRunner, project_dir: pathlib.Path, app_mode: str, monkeypatch: pytest.MonkeyPatch +): + """Unsupported app modes fail quoting the configured string (not its canonical + alias target) and without the quickstart hint.""" + _spy_make_bundle(monkeypatch) + _write_pyproject( + project_dir, + f""" + [project] + name = "hello_app" + version = "0.0.1" + + [tool.rsconnect] + app_mode = "{app_mode}" + entrypoint = "app.py" + """, + ) + (project_dir / "app.py").touch() + + result = runner.invoke( + cli, + ["deploy", "pyproject", str(project_dir), "-s", "http://example.invalid", "-k", "fake-key"], + ) + + assert result.exit_code != 0 + combined = result.output + (result.stderr if result.stderr_bytes else "") + assert f"Unsupported app_mode '{app_mode}' in [tool.rsconnect]" in combined + assert "quickstart" not in combined.lower() + + _EXPRESS_APP = "from shiny.express import ui\n\nui.h1('hi')\n" diff --git a/tests/test_write_manifest_pyproject.py b/tests/test_write_manifest_pyproject.py new file mode 100644 index 00000000..ce047642 --- /dev/null +++ b/tests/test_write_manifest_pyproject.py @@ -0,0 +1,454 @@ +""" +Acceptance tests for ``rsconnect write-manifest pyproject``. + +Tests exercise the CLI via ``click.testing.CliRunner`` and assert the real +``manifest.json`` written into the project directory, mirroring the shape of +``tests/test_deploy_pyproject.py``. Python environment inspection and the +Quarto executable are monkeypatched at the boundary so no subprocess runs, +except one end-to-end test that exercises the real environment inspector. +""" + +from __future__ import annotations + +import json +import pathlib +import textwrap +import typing + +import pytest +from click.testing import CliRunner + +from rsconnect.bundle import make_manifest_bundle +from rsconnect.main import cli + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def project_dir(tmp_path: pathlib.Path) -> pathlib.Path: + """A fresh directory; tests populate ``pyproject.toml`` as they need.""" + project = tmp_path / "hello_app" + project.mkdir() + return project + + +def _write_pyproject(project: pathlib.Path, body: str) -> None: + (project / "pyproject.toml").write_text(textwrap.dedent(body)) + + +def _read_manifest(project: pathlib.Path) -> dict[str, typing.Any]: + return json.loads((project / "manifest.json").read_text()) + + +def _fake_python_environment(monkeypatch: pytest.MonkeyPatch) -> dict[str, typing.Any]: + """Replace subprocess-based environment inspection with a canned Environment. + + Mirrors the real inspector's filename behavior: a pyproject.toml source + yields a generated virtual ``requirements.txt``, a ``uv.lock`` source yields + ``requirements.txt.lock``, and any other requirements file is read as-is. + Captures the ``requirements_file`` handed to ``create_python_environment`` + so tests can assert the requirements-source precedence without running pip. + """ + captured: dict[str, typing.Any] = {} + + from rsconnect import main as main_mod + from rsconnect.environment import Environment + + def fake_create(cls: typing.Any, directory: str, **kwargs: typing.Any) -> Environment: + requirements_file = kwargs.get("requirements_file") + captured["directory"] = directory + captured["requirements_file"] = requirements_file + if requirements_file == "uv.lock": + filename = "requirements.txt.lock" + contents = "# requirements.txt.lock generated from uv.lock by rsconnect-python\nflask==2.0.0\n" + elif requirements_file in (None, "pyproject.toml"): + filename = "requirements.txt" + contents = "# requirements.txt generated from pyproject.toml by rsconnect-python\nflask\n" + else: + filename = requirements_file + contents = "flask\n" + return Environment.from_dict( + { + "contents": contents, + "filename": filename, + "locale": "en_US.UTF-8", + "package_manager": "pip", + "pip": "23.0.1", + "python": "3.11.0", + "source": "file", + } + ) + + monkeypatch.setattr(main_mod.Environment, "create_python_environment", classmethod(fake_create)) + return captured + + +def _fake_quarto(monkeypatch: pytest.MonkeyPatch, engines: list[str]) -> None: + """Stub the Quarto executable lookup/inspection at the main.py boundary.""" + from rsconnect import main as main_mod + + monkeypatch.setattr(main_mod, "which_quarto", lambda quarto=None: "quarto") + monkeypatch.setattr( + main_mod, + "quarto_inspect", + lambda quarto, path: {"quarto": {"version": "1.4.0"}, "engines": engines}, + ) + monkeypatch.setattr(main_mod, "validate_quarto_engines", lambda inspect: inspect["engines"]) + + +_NOTEBOOK_JSON = '{"cells": [], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}\n' + + +def _pyproject_body(app_mode: str, entrypoint: str) -> str: + return f""" + [project] + name = "hello_app" + version = "0.0.1" + dependencies = ["flask"] + + [tool.rsconnect] + app_mode = "{app_mode}" + entrypoint = "{entrypoint}" + title = "hello_app" + """ + + +# --------------------------------------------------------------------------- +# Dispatch by app_mode +# --------------------------------------------------------------------------- + + +DISPATCH_MATRIX: list[typing.Any] = [ + pytest.param("python-streamlit", "app.py", "app.py", id="streamlit"), + pytest.param("python-shiny", "app.py", "app.py", id="shiny"), + pytest.param("python-fastapi", "app:app", "app:app", id="fastapi"), + pytest.param("python-api", "app:app", "app:app", id="api"), + pytest.param("jupyter-static", "notebook.ipynb", "notebook.ipynb", id="jupyter-static"), + pytest.param("jupyter-voila", "notebook.ipynb", "notebook.ipynb", id="voila"), +] + + +@pytest.mark.parametrize("app_mode,entrypoint,expected_entrypoint", DISPATCH_MATRIX) +def test_write_manifest_pyproject_writes_manifest_per_app_mode( + runner: CliRunner, + project_dir: pathlib.Path, + app_mode: str, + entrypoint: str, + expected_entrypoint: str, + monkeypatch: pytest.MonkeyPatch, +): + """Each ``[tool.rsconnect].app_mode`` produces a manifest.json with the matching appmode.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _pyproject_body(app_mode, entrypoint)) + if entrypoint.endswith(".ipynb"): + (project_dir / entrypoint).write_text(_NOTEBOOK_JSON) + else: + (project_dir / "app.py").write_text("# plain app, not Shiny Express\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert manifest["metadata"]["appmode"] == app_mode + assert manifest["metadata"]["entrypoint"] == expected_entrypoint + # The inspector turns pyproject.toml's dependencies into a virtual + # requirements.txt, which must also land on disk next to the manifest. + assert manifest["python"]["package_manager"]["package_file"] == "requirements.txt" + assert "generated from pyproject.toml" in (project_dir / "requirements.txt").read_text() + # Closed loop: deploying from the written manifest must find every file. + with make_manifest_bundle(str(project_dir / "manifest.json")): + pass + + +@pytest.mark.parametrize("app_mode", ["quarto-static", "quarto-shiny"]) +def test_write_manifest_pyproject_quarto_modes( + runner: CliRunner, project_dir: pathlib.Path, app_mode: str, monkeypatch: pytest.MonkeyPatch +): + """Quarto modes inspect the project and write the manifest next to pyproject.toml.""" + _fake_quarto(monkeypatch, engines=["markdown"]) + _write_pyproject(project_dir, _pyproject_body(app_mode, "report.qmd")) + (project_dir / "report.qmd").write_text("# Report\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert manifest["metadata"]["appmode"] == app_mode + assert manifest["quarto"]["engines"] == ["markdown"] + assert "report.qmd" in manifest["files"] + + +def test_write_manifest_pyproject_quarto_jupyter_engine_inspects_python( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A Quarto project on the Jupyter engine also embeds the Python environment.""" + captured = _fake_python_environment(monkeypatch) + _fake_quarto(monkeypatch, engines=["jupyter"]) + _write_pyproject(project_dir, _pyproject_body("quarto-static", "report.qmd")) + (project_dir / "report.qmd").write_text("# Report\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert captured["requirements_file"] == "pyproject.toml" + manifest = _read_manifest(project_dir) + assert manifest["metadata"]["appmode"] == "quarto-static" + assert "python" in manifest + assert (project_dir / "requirements.txt").exists() + + +def test_write_manifest_pyproject_shiny_express_entrypoint_is_rewritten( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A Shiny Express app gets the same ``shiny.express.app:`` entrypoint deploy uses.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _pyproject_body("python-shiny", "app.py")) + (project_dir / "app.py").write_text("from shiny.express import ui\n\nui.h1('hi')\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert manifest["metadata"]["entrypoint"].startswith("shiny.express.app:") + + +# --------------------------------------------------------------------------- +# Requirements source precedence (mirrors deploy pyproject) +# --------------------------------------------------------------------------- + + +_REQ_PYPROJECT = _pyproject_body("python-api", "app:app") + + +def test_write_manifest_pyproject_defaults_requirements_to_pyproject_toml( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + captured = _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code == 0, result.output + assert captured["requirements_file"] == "pyproject.toml" + assert captured["directory"] == str(project_dir) + + +def test_write_manifest_pyproject_requirements_file_flag_overrides_default( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + captured = _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "uv.lock").write_text("# placeholder\n") + result = runner.invoke(cli, ["write-manifest", "pyproject", "-r", "uv.lock", str(project_dir)]) + assert result.exit_code == 0, result.output + assert captured["requirements_file"] == "uv.lock" + # uv.lock is exported to a virtual requirements.txt.lock, which must land on disk. + assert (project_dir / "requirements.txt.lock").exists() + with make_manifest_bundle(str(project_dir / "manifest.json")): + pass + + +def test_write_manifest_pyproject_honors_requirements_file_from_tool_rsconnect( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + captured = _fake_python_environment(monkeypatch) + _write_pyproject( + project_dir, + """ + [project] + name = "hello_app" + version = "0.0.1" + dependencies = ["flask"] + + [tool.rsconnect] + app_mode = "python-api" + entrypoint = "app:app" + title = "hello_app" + requirements_file = "uv.lock" + """, + ) + (project_dir / "uv.lock").write_text("# placeholder\n") + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code == 0, result.output + assert captured["requirements_file"] == "uv.lock" + + +# --------------------------------------------------------------------------- +# Environment file lands next to the manifest +# --------------------------------------------------------------------------- + + +def test_write_manifest_pyproject_writes_env_file_next_to_subdir_manifest( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """Notebook manifests land next to the entrypoint, and the env file must too.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _pyproject_body("jupyter-static", "nb/notebook.ipynb")) + (project_dir / "nb").mkdir() + (project_dir / "nb" / "notebook.ipynb").write_text(_NOTEBOOK_JSON) + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert (project_dir / "nb" / "requirements.txt").exists() + assert not (project_dir / "requirements.txt").exists() + with make_manifest_bundle(str(project_dir / "nb" / "manifest.json")): + pass + + +def test_write_manifest_pyproject_does_not_overwrite_existing_env_file( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A pre-existing file named like the env file is preserved, with a warning.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "requirements.txt").write_text("hand-written\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert "requirements.txt already exists and will not be overwritten" in result.output + assert (project_dir / "requirements.txt").read_text() == "hand-written\n" + + +def test_write_manifest_pyproject_unmocked_end_to_end(runner: CliRunner, project_dir: pathlib.Path): + """Real environment inspection: default config must yield a deployable manifest.""" + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "app.py").write_text("app = None\n") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = _read_manifest(project_dir) + assert manifest["python"]["package_manager"]["package_file"] == "requirements.txt" + assert "flask" in (project_dir / "requirements.txt").read_text() + with make_manifest_bundle(str(project_dir / "manifest.json")): + pass + + +# --------------------------------------------------------------------------- +# Overwrite guard +# --------------------------------------------------------------------------- + + +def test_write_manifest_pyproject_refuses_to_overwrite_without_flag( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "manifest.json").write_text("{}") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code != 0 + assert "manifest.json already exists" in result.output + assert "--overwrite" in result.output + assert _read_manifest(project_dir) == {} # untouched + + +def test_write_manifest_pyproject_overwrite_flag_replaces_manifest( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "manifest.json").write_text("{}") + + result = runner.invoke(cli, ["write-manifest", "pyproject", "--overwrite", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert _read_manifest(project_dir)["metadata"]["appmode"] == "python-api" + + +def test_write_manifest_pyproject_guards_manifest_next_to_subdir_entrypoint( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """Notebook manifests land next to the entrypoint, so the guard must too.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _pyproject_body("jupyter-static", "nb/notebook.ipynb")) + (project_dir / "nb").mkdir() + (project_dir / "nb" / "notebook.ipynb").write_text(_NOTEBOOK_JSON) + (project_dir / "nb" / "manifest.json").write_text("{}") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code != 0 + assert "manifest.json already exists" in result.output + assert json.loads((project_dir / "nb" / "manifest.json").read_text()) == {} # untouched + + +def test_write_manifest_pyproject_stale_root_manifest_does_not_block_subdir_entrypoint( + runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch +): + """A manifest.json at the project root is not the writer's destination and must not block.""" + _fake_python_environment(monkeypatch) + _write_pyproject(project_dir, _pyproject_body("jupyter-static", "nb/notebook.ipynb")) + (project_dir / "nb").mkdir() + (project_dir / "nb" / "notebook.ipynb").write_text(_NOTEBOOK_JSON) + (project_dir / "manifest.json").write_text("{}") + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + manifest = json.loads((project_dir / "nb" / "manifest.json").read_text()) + assert manifest["metadata"]["appmode"] == "jupyter-static" + assert _read_manifest(project_dir) == {} # stale root manifest untouched + + +# --------------------------------------------------------------------------- +# Configuration errors +# --------------------------------------------------------------------------- + + +def test_write_manifest_pyproject_errors_on_missing_section(runner: CliRunner, project_dir: pathlib.Path): + _write_pyproject( + project_dir, + """ + [project] + name = "hello_app" + version = "0.0.1" + """, + ) + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + # The error carries the copy-pasteable TOML snippet plus the quickstart hint. + assert "[tool.rsconnect]" in result.output + assert 'app_mode = "' in result.output + assert 'entrypoint = "' in result.output + assert "quickstart" in result.output.lower() + + +def test_write_manifest_pyproject_errors_on_missing_pyproject(runner: CliRunner, project_dir: pathlib.Path): + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + assert "pyproject.toml not found" in result.output + assert "quickstart" in result.output.lower() + + +def test_write_manifest_pyproject_errors_on_malformed_pyproject(runner: CliRunner, project_dir: pathlib.Path): + (project_dir / "pyproject.toml").write_text("[tool.rsconnect\napp_mode =") + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + assert "could not be parsed" in result.output + assert "quickstart" in result.output.lower() + + +@pytest.mark.parametrize( + "app_mode", + ["no-such-mode", "python-dash", "nodejs-api"], + ids=["unknown", "valid-but-unhandled", "aliased"], +) +def test_write_manifest_pyproject_errors_on_unsupported_app_mode( + runner: CliRunner, project_dir: pathlib.Path, app_mode: str +): + """Unsupported app modes fail with deploy's exact message and no quickstart hint.""" + _write_pyproject(project_dir, _pyproject_body(app_mode, "app.py")) + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + assert result.exit_code != 0 + assert f"Unsupported app_mode '{app_mode}' in [tool.rsconnect]" in result.output + assert "quickstart" not in result.output.lower() From 355d184abf2c5de2c3090732af84f27889a1bc69 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 11 Jun 2026 18:19:12 +0200 Subject: [PATCH 2/3] Make so that the requirements.txt is updated when necessary --- rsconnect/main.py | 23 +++++++------ tests/test_write_manifest_pyproject.py | 45 +++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index c3350710..3df3fb98 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -3207,9 +3207,10 @@ def write_manifest_quarto( help=( "Create a manifest.json file for later deployment, for content described by a project's " "pyproject.toml. The given directory must contain a pyproject.toml with a [tool.rsconnect] " - "table specifying app_mode and entrypoint. This will also create the environment file the " - 'manifest references (e.g. "requirements.txt") if one does not exist. Designed as the ' - "write-manifest partner for projects scaffolded by 'rsconnect quickstart'." + "table specifying app_mode and entrypoint. This will also write the environment file the " + 'manifest references (e.g. "requirements.txt"), regenerating it on each run unless it is ' + "itself the requirements source. Designed as the write-manifest partner for projects " + "scaffolded by 'rsconnect quickstart'." ), no_args_is_help=True, ) @@ -3365,16 +3366,14 @@ def inspect_python_environment() -> Environment: # The manifest references environment.filename (e.g. a requirements.txt # generated from pyproject.toml's dependencies), so that file must exist - # next to manifest.json or deploying from the manifest fails. Mirror the - # sibling write-manifest commands: write it, or warn when a file with that - # name already exists. Quarto with non-Jupyter engines has no environment. + # next to manifest.json or deploying from the manifest fails. Regenerate it + # on every run so deployments pick up dependency changes, but never touch + # the requirements source itself: it is user-managed, and the inspector + # strips rsconnect lines from the contents it returns. Quarto with + # non-Jupyter engines has no environment. if environment is not None: - if (manifest_dir / environment.filename).exists(): - click.secho( - " Warning: %s already exists and will not be overwritten." % environment.filename, - fg="yellow", - ) - else: + requirements_source = (Path(directory) / target.requirements_file).resolve() + if requirements_source != (manifest_dir / environment.filename).resolve(): with cli_feedback("Creating %s" % environment.filename): write_environment_file(environment, str(manifest_dir)) diff --git a/tests/test_write_manifest_pyproject.py b/tests/test_write_manifest_pyproject.py index ce047642..a2543a93 100644 --- a/tests/test_write_manifest_pyproject.py +++ b/tests/test_write_manifest_pyproject.py @@ -302,19 +302,54 @@ def test_write_manifest_pyproject_writes_env_file_next_to_subdir_manifest( pass -def test_write_manifest_pyproject_does_not_overwrite_existing_env_file( +def test_write_manifest_pyproject_regenerates_stale_env_file( runner: CliRunner, project_dir: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - """A pre-existing file named like the env file is preserved, with a warning.""" + """A stale generated env file is rewritten so deployments ship current dependencies.""" _fake_python_environment(monkeypatch) _write_pyproject(project_dir, _REQ_PYPROJECT) - (project_dir / "requirements.txt").write_text("hand-written\n") + (project_dir / "requirements.txt").write_text("stale-dependency==0.1\n") result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) assert result.exit_code == 0, result.output - assert "requirements.txt already exists and will not be overwritten" in result.output - assert (project_dir / "requirements.txt").read_text() == "hand-written\n" + contents = (project_dir / "requirements.txt").read_text() + assert "stale-dependency" not in contents + assert "flask" in contents + + +def test_write_manifest_pyproject_never_rewrites_explicit_requirements_source( + runner: CliRunner, project_dir: pathlib.Path +): + """When requirements.txt IS the configured source, it must stay untouched. + + Unmocked on purpose: the real inspector strips rsconnect lines from the + contents it returns, so a wrongful rewrite would corrupt the user's file. + """ + _write_pyproject( + project_dir, + """ + [project] + name = "hello_app" + version = "0.0.1" + dependencies = ["flask"] + + [tool.rsconnect] + app_mode = "python-api" + entrypoint = "app:app" + requirements_file = "requirements.txt" + """, + ) + (project_dir / "app.py").write_text("app = None\n") + source = "flask\nrsconnect-python==1.25.0\n" + (project_dir / "requirements.txt").write_text(source) + + result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + + assert result.exit_code == 0, result.output + assert (project_dir / "requirements.txt").read_text() == source + with make_manifest_bundle(str(project_dir / "manifest.json")): + pass def test_write_manifest_pyproject_unmocked_end_to_end(runner: CliRunner, project_dir: pathlib.Path): From c23ce934494a84d271ca9bb85641d81715d47461 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Thu, 11 Jun 2026 18:32:44 +0200 Subject: [PATCH 3/3] add changelog --- docs/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 91ff0bdc..e78d2f54 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `pyproject.toml` with a `[tool.rsconnect]` table containing `app_mode` and `entrypoint`. Designed as the deploy partner for projects scaffolded by `rsconnect quickstart` but works with any conforming `pyproject.toml`. +- `rsconnect write-manifest pyproject` command for creating a `manifest.json` + from a project's `pyproject.toml` with a `[tool.rsconnect]` table. Also + writes the environment file the manifest references (e.g. `requirements.txt`), + regenerating it on each run unless it is itself the requirements source. ### Changed