From 2dbf340bfe5949224b8667fbd5f75a23526941c7 Mon Sep 17 00:00:00 2001 From: Jacob Woliver Date: Fri, 6 Nov 2026 10:20:51 -0500 Subject: [PATCH 1/4] fix: Don't resolve dependencies for deploy notebook --static (#923) The static bundle contains only the HTML rendered locally by nbconvert, but deploy_notebook resolved a requirements file, inspected the Python environment and read renv.lock before branching on --static, then threw all of it away. This required a requirements file (or --force-generate), failed on an incompatible renv.lock, and required rsconnect in the --python interpreter. With --static, only look up the interpreter that runs nbconvert. --- docs/CHANGELOG.md | 5 ++++ rsconnect/main.py | 35 +++++++++++++--------- tests/test_main.py | 73 +++++++++++++++++++++++++++++++--------------- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d850cb9..c5b0b3d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `deploy notebook --static` no longer requires a requirements file or + `--force-generate`, and no longer reads `renv.lock`. The static bundle contains + only the rendered HTML, so the only requirement is a Python interpreter with + `nbconvert`, the current one or the one given with `--python`. That interpreter + no longer needs rsconnect installed. (#923) - **Breaking:** Posit Connect content can be targeted only by GUID. `--app-id` with a numeric Connect content ID fails with an error explaining how to find the content's GUID, and rsconnect-python no longer calls the legacy diff --git a/rsconnect/main.py b/rsconnect/main.py index fda97767..e652ab44 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -122,7 +122,7 @@ ) from .environment_node import NodeEnvironment from .environment_r import REnvironment -from .environment import Environment, PackageInstaller, fake_module_file_from_directory +from .environment import Environment, PackageInstaller, fake_module_file_from_directory, which_python from .exception import RSConnectException from .git_metadata import detect_git_metadata from .json_web_token import ( @@ -2226,15 +2226,23 @@ def deploy_notebook( app_mode = AppModes.JUPYTER_NOTEBOOK if not static else AppModes.STATIC base_dir = dirname(file) - requirements_file = resolve_requirements_file(base_dir, requirements_file, force_generate) - environment = Environment.create_python_environment( - base_dir, - requirements_file=requirements_file, - python=python, - override_python_version=override_python_version, - package_manager=package_installer, - ) - r_environment = None if exclude_renv else REnvironment.create(base_dir) + python_interpreter: Optional[str] = None + environment: Optional[Environment] = None + r_environment: Optional[REnvironment] = None + if static: + # The notebook is rendered locally and the bundle holds only the HTML, so Connect + # never builds an environment for it. Only an interpreter to run nbconvert is needed. + python_interpreter = which_python(python) + else: + requirements_file = resolve_requirements_file(base_dir, requirements_file, force_generate) + environment = Environment.create_python_environment( + base_dir, + requirements_file=requirements_file, + python=python, + override_python_version=override_python_version, + package_manager=package_installer, + ) + r_environment = None if exclude_renv else REnvironment.create(base_dir) ce = RSConnectExecutor( ctx=ctx, @@ -2265,16 +2273,17 @@ def deploy_notebook( ce.metadata = deploy_metadata ce.validate_server().validate_app_mode(app_mode=app_mode) - if app_mode == AppModes.STATIC: - assert environment.python_interpreter is not None + if static: + assert python_interpreter is not None ce.make_bundle( make_notebook_html_bundle, file, - environment.python_interpreter, + python_interpreter, hide_all_input, hide_tagged_input, ) else: + assert environment is not None ce.make_bundle( make_notebook_source_bundle, file, diff --git a/tests/test_main.py b/tests/test_main.py index f66d7766..f4afa097 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,6 @@ import sys import venv from os.path import join -from types import SimpleNamespace from unittest import TestCase, mock import click @@ -306,32 +305,63 @@ def post_application_deploy_callback(request, uri, response_headers): if original_server_value: os.environ["CONNECT_SERVER"] = original_server_value - def test_deploy_notebook_static_passes_interpreter_not_version(self): - # Regression test for #721: deploy_notebook must hand make_notebook_html_bundle - # the local interpreter *path* (used to run nbconvert), not environment.python, - # which is only a version string and made nbconvert fail with "No such file or - # directory". - fake_env = SimpleNamespace(python="3.12.1", python_interpreter=sys.executable) - - with mock.patch("rsconnect.main.Environment.create_python_environment", return_value=fake_env), mock.patch( - "rsconnect.main.RSConnectExecutor" - ) as MockExecutor: - ce = MockExecutor.return_value - - runner = CliRunner() + @pytest.mark.parametrize( + "extra_files", + [ + pytest.param({}, id="notebook-only"), + # An renv.lock that R dependency detection rejects. requirements.txt is present + # so this case fails on R detection, not on the missing requirements file. + pytest.param( + {"requirements.txt": "", "renv.lock": json.dumps({"R": {"Version": "4.4.1"}})}, + id="incompatible-renv-lock", + ), + ], + ) + def test_deploy_notebook_static_needs_no_environment(self, tmp_path, extra_files): + # Regression test for #923: the static bundle holds only the rendered HTML, so + # deploying it must not require a requirements file or resolve Python or R + # dependencies. Also covers #721: make_notebook_html_bundle gets the local + # interpreter *path* (used to run nbconvert), not a Python version string. + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + for name, contents in extra_files.items(): + (tmp_path / name).write_text(contents) + + with mock.patch("rsconnect.main.RSConnectExecutor") as mock_executor: args = apply_common_args( - ["deploy", "notebook", get_dir(join("pip1", "dummy.ipynb"))], + ["deploy", "notebook", str(notebook), "--static", "--no-verify"], server="http://fake_server", key="FAKE_API_KEY", ) - args += ["--static", "--no-verify"] - result = runner.invoke(cli, args) + result = CliRunner().invoke(cli, args) assert result.exit_code == 0, result.output # make_bundle(make_notebook_html_bundle, file, , hide_all, hide_tagged) - bundle_args = ce.make_bundle.call_args.args + bundle_args = mock_executor.return_value.make_bundle.call_args.args assert bundle_args[0] is make_notebook_html_bundle assert bundle_args[2] == sys.executable + assert sorted(p.name for p in tmp_path.iterdir()) == sorted(["nb.ipynb", *extra_files]) + + def test_deploy_notebook_static_renders_with_python_option(self, tmp_path): + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + # Never run: make_bundle is mocked. --python only has to name an executable file. + python = tmp_path / "python" + python.write_text("") + python.chmod(0o755) + + with mock.patch("rsconnect.main.RSConnectExecutor") as mock_executor: + args = apply_common_args( + ["deploy", "notebook", str(notebook), "--static", "--no-verify", "--python", str(python)], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + bundle_args = mock_executor.return_value.make_bundle.call_args.args + assert bundle_args[0] is make_notebook_html_bundle + assert bundle_args[2] == str(python) @pytest.mark.parametrize( ("contents", "diagnostic", "missing_nbconvert"), @@ -368,14 +398,11 @@ def test_deploy_static_notebook_render_failure(self, tmp_path, capfd, contents, env_dir = tmp_path / "empty-env" venv.EnvBuilder(with_pip=False, symlinks=os.name != "nt").create(env_dir) interpreter = str(env_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python")) - fake_env = SimpleNamespace(python="3.12.1", python_interpreter=interpreter) - with mock.patch("rsconnect.main.Environment.create_python_environment", return_value=fake_env), mock.patch( - "rsconnect.main.RSConnectExecutor" - ) as mock_executor: + with mock.patch("rsconnect.main.RSConnectExecutor") as mock_executor: mock_executor.return_value.make_bundle.side_effect = lambda func, *args: func(*args) args = apply_common_args( - ["deploy", "notebook", str(notebook), "--static", "--no-verify"], + ["deploy", "notebook", str(notebook), "--static", "--no-verify", "--python", interpreter], server="http://fake_server", key="FAKE_API_KEY", ) From be41323e7dd4e73f9b70782512cc008ec90bfccf Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Fri, 6 Nov 2026 12:14:08 -0500 Subject: [PATCH 2/4] fix: Find the notebook's project environment for deploy notebook --static #920 added project environment discovery relative to the content directory, but the static path called which_python without one, so it searched from the current working directory instead of the notebook's. Co-Authored-By: Claude Opus 5.5 --- rsconnect/main.py | 2 +- tests/test_main.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 38b8f9a8..8b9d8158 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2237,7 +2237,7 @@ def deploy_notebook( if static: # The notebook is rendered locally and the bundle holds only the HTML, so Connect # never builds an environment for it. Only an interpreter to run nbconvert is needed. - python_interpreter = which_python(python) + python_interpreter = which_python(python, base_dir) else: requirements_file = resolve_requirements_file(base_dir, requirements_file, force_generate) environment = Environment.create_python_environment( diff --git a/tests/test_main.py b/tests/test_main.py index 09c2fe48..a9f20358 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -371,6 +371,35 @@ def test_deploy_notebook_static_renders_with_python_option(self, tmp_path): assert bundle_args[0] is make_notebook_html_bundle assert bundle_args[2] == str(python) + def test_deploy_notebook_static_renders_with_notebook_project_venv(self, tmp_path, monkeypatch): + project = tmp_path / "project" + notebook = project / "nb.ipynb" + project.mkdir() + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + # Never run: make_bundle is mocked. Discovery only needs an executable file. + python = project / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + python.parent.mkdir(parents=True) + python.write_text("") + python.chmod(0o755) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + monkeypatch.delenv("CONDA_PREFIX", raising=False) + + with mock.patch("rsconnect.main.RSConnectExecutor") as mock_executor: + args = apply_common_args( + ["deploy", "notebook", str(notebook), "--static", "--no-verify"], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + bundle_args = mock_executor.return_value.make_bundle.call_args.args + assert bundle_args[0] is make_notebook_html_bundle + assert bundle_args[2] == str(python) + @pytest.mark.parametrize( ("contents", "diagnostic", "missing_nbconvert"), [ From a6b519e44fedb7c4f5ff7b9612b0d5c084c5f97c Mon Sep 17 00:00:00 2001 From: Jacob Woliver Date: Fri, 6 Nov 2026 14:39:53 -0500 Subject: [PATCH 3/4] review: Clarify static notebook requirements and test invalid --python The rendering environment runs the notebook, so it needs ipykernel and the notebook's imports, not only nbconvert. Cover --python validation on the static path with a directory, which reaches which_python on every platform. --- docs/CHANGELOG.md | 5 +++-- tests/test_main.py | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ff6b5a5..85d64d06 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `deploy notebook --static` no longer requires a requirements file or `--force-generate`, and no longer reads `renv.lock`. The static bundle contains - only the rendered HTML, so the only requirement is `nbconvert` in the selected - Python environment. That interpreter no longer needs rsconnect installed. (#923) + only the rendered HTML, so the selected Python environment only needs `nbconvert`, + `ipykernel`, and the packages the notebook imports. That environment no longer + needs rsconnect installed. (#923) - `--override-python-version` with a major and minor version, such as `3.11`, requests the latest Python patch release in that series instead of `3.11.0`. This requires Posit Connect 2025.03.0 or later; older servers use the local diff --git a/tests/test_main.py b/tests/test_main.py index aed4d0d9..fa345569 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -372,6 +372,26 @@ def test_deploy_notebook_static_renders_with_python_option(self, tmp_path): assert bundle_args[0] is make_notebook_html_bundle assert bundle_args[2] == str(python) + def test_deploy_notebook_static_rejects_invalid_python_option(self, tmp_path): + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + # click rejects a missing --python path, and every existing file is executable on + # Windows. A directory is the invalid value that reaches which_python everywhere. + python = tmp_path / "not-python" + python.mkdir() + + with mock.patch("rsconnect.main.RSConnectExecutor") as mock_executor: + args = apply_common_args( + ["deploy", "notebook", str(notebook), "--static", "--no-verify", "--python", str(python)], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 1 + assert f"The path '{python}' is a directory. Expected a Python executable." in result.output + mock_executor.return_value.make_bundle.assert_not_called() + def test_deploy_notebook_static_renders_with_notebook_project_venv(self, tmp_path, monkeypatch): project = tmp_path / "project" notebook = project / "nb.ipynb" From 439a6755eedea0bbc7786396de3b701978ec758c Mon Sep 17 00:00:00 2001 From: Jacob Woliver Date: Fri, 6 Nov 2026 15:15:41 -0500 Subject: [PATCH 4/4] feat: Warn about options that deploy notebook --static ignores (#925) * feat: Warn about options that deploy notebook --static ignores A static notebook bundle contains only the rendered HTML, so options that shape a source bundle's Python or R environment (--requirements-file, --force-generate, --package-installer, --override-python-version, --image, the environment management options, --exclude-renv) and extra files have no effect. Warn when any of them is given explicitly instead of silently ignoring it. * review: Test that only --static warns, and ignore option order Cover deploy notebook without --static passing options --static ignores, so moving the warning out of the static branch fails a test. Compare the warned options as a set, since their order comes from the decorators. --- docs/CHANGELOG.md | 3 ++ rsconnect/main.py | 36 ++++++++++++++++++++++ tests/test_main.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85d64d06..7a558367 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 only the rendered HTML, so the selected Python environment only needs `nbconvert`, `ipykernel`, and the packages the notebook imports. That environment no longer needs rsconnect installed. (#923) +- `deploy notebook --static` warns about options that have no effect on the + rendered HTML, such as `--requirements-file`, `--force-generate`, `--image`, and + the environment management options, and about extra files, which aren't included. - `--override-python-version` with a major and minor version, such as `3.11`, requests the latest Python patch release in that series instead of `3.11.0`. This requires Posit Connect 2025.03.0 or later; older servers use the local diff --git a/rsconnect/main.py b/rsconnect/main.py index 68d20324..85c87292 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2108,6 +2108,41 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): warn_user(" Warning: the existing %s file will not be used or considered." % requirements_file_name) +# deploy notebook parameters that only affect a source bundle's Python or R environment. +# A static notebook bundle contains only the rendered HTML, so they have no effect. +_STATIC_NOTEBOOK_IGNORED_PARAMS = ( + "requirements_file", + "force_generate", + "package_installer", + "override_python_version", + "image", + "disable_env_management", + "env_management_py", + "env_management_r", + "exclude_renv", +) + + +def _warn_on_options_ignored_by_static_notebook(ctx: click.Context, extra_files: tuple[str, ...]) -> None: + """Warn about deploy notebook options and extra files that --static ignores.""" + ignored_options = [ + next(opt for opt in param.opts if opt.startswith("--")) + for param in ctx.command.params + if param.name in _STATIC_NOTEBOOK_IGNORED_PARAMS + and validation.get_parameter_source_name_from_ctx(param.name, ctx) not in ("DEFAULT", "") + ] + if ignored_options: + warn_user( + " Warning: --static deploys only the rendered HTML, so these options have no effect: %s" + % ", ".join(ignored_options) + ) + if extra_files: + warn_user( + " Warning: --static deploys only the rendered HTML, so these extra files are not included: %s" + % ", ".join(extra_files) + ) + + # noinspection SpellCheckingInspection,DuplicatedCode @deploy.command( name="notebook", @@ -2240,6 +2275,7 @@ def deploy_notebook( if static: # The notebook is rendered locally and the bundle holds only the HTML, so Connect # never builds an environment for it. Only an interpreter to run nbconvert is needed. + _warn_on_options_ignored_by_static_notebook(ctx, extra_files) python_interpreter = which_python(python, base_dir) else: requirements_file = resolve_requirements_file(base_dir, requirements_file, force_generate) diff --git a/tests/test_main.py b/tests/test_main.py index fa345569..78c67f30 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -350,6 +350,7 @@ def test_deploy_notebook_static_needs_no_environment(self, tmp_path, extra_files assert bundle_args[0] is make_notebook_html_bundle assert bundle_args[2] == sys.executable assert sorted(p.name for p in tmp_path.iterdir()) == sorted(["nb.ipynb", *extra_files]) + assert "--static deploys only the rendered HTML" not in result.output def test_deploy_notebook_static_renders_with_python_option(self, tmp_path): notebook = tmp_path / "nb.ipynb" @@ -421,6 +422,81 @@ def test_deploy_notebook_static_renders_with_notebook_project_venv(self, tmp_pat assert bundle_args[0] is make_notebook_html_bundle assert bundle_args[2] == str(python) + @pytest.mark.parametrize( + ("options", "ignored"), + [ + (["--requirements-file", "requirements.txt"], ["--requirements-file"]), + (["--force-generate"], ["--force-generate"]), + (["--package-installer", "uv"], ["--package-installer"]), + (["--override-python-version", "3.12"], ["--override-python-version"]), + (["--image", "rstudio/content-base"], ["--image"]), + (["--disable-env-management"], ["--disable-env-management"]), + (["--disable-env-management-py"], ["--disable-env-management-py"]), + (["--disable-env-management-r"], ["--disable-env-management-r"]), + (["--exclude-renv"], ["--exclude-renv"]), + (["-r", "requirements.txt", "-g"], ["--requirements-file", "--force-generate"]), + ], + ) + def test_deploy_notebook_static_warns_on_ignored_options(self, tmp_path, options, ignored): + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + + with mock.patch("rsconnect.main.RSConnectExecutor"): + args = apply_common_args( + ["deploy", "notebook", str(notebook), "--static", "--no-verify", *options], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + prefix = "Warning: --static deploys only the rendered HTML, so these options have no effect: " + [warning] = [line for line in result.output.splitlines() if prefix in line] + # The warning lists options in declaration order, which isn't part of the contract. + assert sorted(warning.split(prefix, 1)[1].split(", ")) == sorted(ignored) + assert "extra files" not in result.output + + def test_deploy_notebook_without_static_does_not_warn_on_static_ignored_options(self, tmp_path): + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + data_file = tmp_path / "data.csv" + data_file.write_text("") + + with mock.patch("rsconnect.main.Environment.create_python_environment"), mock.patch( + "rsconnect.main.RSConnectExecutor" + ): + args = apply_common_args( + ["deploy", "notebook", str(notebook), str(data_file), "--no-verify", "-r", "requirements.txt", "-g"], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + assert "Warning: --static deploys only the rendered HTML" not in result.output + + def test_deploy_notebook_static_warns_on_extra_files(self, tmp_path): + notebook = tmp_path / "nb.ipynb" + shutil.copy(get_dir(join("pip1", "dummy.ipynb")), notebook) + data_files = [tmp_path / "data.csv", tmp_path / "helpers.py"] + for data_file in data_files: + data_file.write_text("") + + with mock.patch("rsconnect.main.RSConnectExecutor"): + args = apply_common_args( + ["deploy", "notebook", str(notebook), *map(str, data_files), "--static", "--no-verify"], + server="http://fake_server", + key="FAKE_API_KEY", + ) + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + assert ( + "Warning: --static deploys only the rendered HTML, so these extra files are not included: " + f"{data_files[0]}, {data_files[1]}\n" in result.output + ) + assert "have no effect" not in result.output + @pytest.mark.parametrize( ("contents", "diagnostic", "missing_nbconvert"), [