From 85ed94c66b0a5dda889bf44ff5b906dfab935a81 Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 11:50:43 -0700 Subject: [PATCH 1/6] write requirements files with the bytes the manifest checksums --- docs/CHANGELOG.md | 5 ++ rsconnect/bundle.py | 13 +++--- rsconnect/subprocesses/inspect_environment.py | 3 +- tests/test_bundle.py | 46 +++++++++++++++++++ tests/test_environment.py | 9 ++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a071c60fc..f2fbf32a2 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 +- `write-manifest` now writes `requirements.txt` with the same bytes that it uses for + the manifest checksum. On Windows, the file had CRLF line endings and the checksum did + not match. An existing requirements file with CRLF line endings also has the correct + checksum now. + ## [1.31.1] - 2026-09-10 - Deploys to Posit Connect Cloud now request the Python version the content needs, diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index f3d896c6c..ea5edde14 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -565,10 +565,9 @@ def write_manifest( if environment.source == "file": skipped.append(environment_relative_path) else: - with open(environment_file, "w") as f: - f.write(environment.contents) - created.append(environment_relative_path) - logger.debug("wrote environment file: %s", environment_file) + write_environment_file(environment, output_dir) + created.append(environment_relative_path) + logger.debug("wrote environment file: %s", environment_file) return created, skipped @@ -2365,8 +2364,10 @@ def write_environment_file( :param directory: the directory where the file should be written. """ environment_file_path = join(directory, environment.filename) - with open(environment_file_path, "w") as f: - f.write(environment.contents) + # Write bytes so that the file matches the manifest checksum on all platforms. + # Text mode changes line endings and encoding on Windows. + with open(environment_file_path, "wb") as f: + f.write(to_bytes(environment.contents)) def describe_manifest( diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 4e982612a..f4cd3cd0e 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -148,7 +148,8 @@ def output_file(dirname: str, filename: str, package_manager: str): if not os.path.exists(path): return None - with open(path, "r") as f: + # Keep CRLF line endings. Text mode changes them to LF. + with open(path, "r", newline="") as f: data = f.read() data = "\n".join([line for line in data.split("\n") if "rsconnect" not in line]) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index d6aa5d4b3..0bfaf26a4 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import builtins import io import json import os @@ -7,6 +8,7 @@ import tempfile from os.path import abspath, basename, dirname, join from pathlib import Path +from typing import Any from unittest import TestCase, mock import pytest @@ -37,6 +39,7 @@ make_tensorflow_manifest, make_voila_bundle, default_title_from_bundle, + file_checksum, open_bundle, read_bundle_app_mode, read_bundle_manifest, @@ -45,12 +48,16 @@ validate_entry_point, validate_extra_files, validate_node_entry_point, + write_api_manifest_json, + write_environment_file, + write_manifest, ) from rsconnect.shiny_express import escape_to_var_name from rsconnect.environment_node import NodeEnvironment from rsconnect.environment import Environment, PackageInstaller from rsconnect.exception import RSConnectException from rsconnect.models import AppModes +import rsconnect.bundle from .utils import get_dir, get_manifest_path @@ -3442,3 +3449,42 @@ def test_resolve_shiny_express_entrypoint_normalizes_py_extension(tmp_path): def test_resolve_shiny_express_entrypoint_non_express_unchanged(tmp_path): (tmp_path / "app.py").write_text("from shiny import App\n") assert resolve_shiny_express_entrypoint("app.py", str(tmp_path)) == "app.py" + + +@pytest.fixture +def windows_text_mode(monkeypatch: pytest.MonkeyPatch): + """Give `open` in rsconnect.bundle the Windows text-mode defaults: CRLF line endings and cp1252.""" + + def windows_open(file: str, mode: str = "r", *args: Any, **kwargs: Any) -> Any: + if "b" not in mode: + kwargs.setdefault("newline", "\r\n") + kwargs.setdefault("encoding", "cp1252") + return builtins.open(file, mode, *args, **kwargs) + + monkeypatch.setattr(rsconnect.bundle, "open", windows_open, raising=False) + + +def make_generated_environment(contents: str) -> Environment: + environment = Environment.create_python_environment(get_dir("pip1")) + environment.contents = contents + environment.source = "pip_freeze" + return environment + + +def test_write_api_manifest_checksum_matches_environment_file(tmp_path: Path, windows_text_mode: None): + (tmp_path / "app.py").write_text("from shiny import App\n") + environment = make_generated_environment("# café\nshiny==1.0.0\nnumpy\n") + + write_api_manifest_json(str(tmp_path), "app:app", environment, AppModes.PYTHON_SHINY, [], []) + write_environment_file(environment, str(tmp_path)) + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["files"]["requirements.txt"]["checksum"] == file_checksum(tmp_path / "requirements.txt") + + +def test_write_notebook_manifest_keeps_environment_file_bytes(tmp_path: Path, windows_text_mode: None): + environment = make_generated_environment("# café\njupyter\nnumpy\n") + + write_manifest(".", "notebook.ipynb", environment, str(tmp_path)) + + assert (tmp_path / "requirements.txt").read_bytes() == to_bytes(environment.contents) diff --git a/tests/test_environment.py b/tests/test_environment.py index f22bf69ce..a9afe41a4 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -4,6 +4,7 @@ import tempfile import shutil import subprocess +from pathlib import Path from unittest import TestCase from unittest import mock @@ -221,6 +222,14 @@ def test_pyproject_dependencies(tmp_path): assert env.package_manager == "pip" +def test_requirements_file_keeps_crlf_line_endings(tmp_path: Path): + (tmp_path / "requirements.txt").write_bytes(b"numpy\r\npandas\r\n") + + env = detect_environment(str(tmp_path)) + + assert env.contents == "numpy\r\npandas\r\n" + + def test_pyproject_dependencies_missing(tmp_path): project_dir = tmp_path / "project" project_dir.mkdir() From 79859e35261820f790131ae4350b788f393f97be Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 12:02:11 -0700 Subject: [PATCH 2/6] use valid length hashes in uv.lock test fixture allows prerelease tests to pass --- tests/test_environment.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_environment.py b/tests/test_environment.py index a9afe41a4..43fb7f57d 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -149,22 +149,34 @@ def test_uv_lock_export(tmp_path): name = "aiofiles" version = "24.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://example.com/aiofiles-24.1.0.tar.gz", hash = "sha256:1" } -wheels = [{ url = "https://example.com/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:2" }] +[package.sdist] +url = "https://example.com/aiofiles-24.1.0.tar.gz" +hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" +[[package.wheels]] +url = "https://example.com/aiofiles-24.1.0-py3-none-any.whl" +hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://example.com/annotated_doc-0.0.4.tar.gz", hash = "sha256:3" } -wheels = [{ url = "https://example.com/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:4" }] +[package.sdist] +url = "https://example.com/annotated_doc-0.0.4.tar.gz" +hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" +[[package.wheels]] +url = "https://example.com/annotated_doc-0.0.4-py3-none-any.whl" +hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://example.com/annotated_types-0.7.0.tar.gz", hash = "sha256:5" } -wheels = [{ url = "https://example.com/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:6" }] +[package.sdist] +url = "https://example.com/annotated_types-0.7.0.tar.gz" +hash = "sha256:5555555555555555555555555555555555555555555555555555555555555555" +[[package.wheels]] +url = "https://example.com/annotated_types-0.7.0-py3-none-any.whl" +hash = "sha256:6666666666666666666666666666666666666666666666666666666666666666" [[package]] name = "demo" From d1b38e9ca2822a8c3dfd9471ac392a97d08267f7 Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 17:42:37 -0700 Subject: [PATCH 3/6] keep CRLF line endings when main() strips requirement lines --- rsconnect/subprocesses/inspect_environment.py | 4 +++- tests/test_bundle.py | 11 +++++++++++ tests/test_environment.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index f4cd3cd0e..ab0f598ac 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -315,7 +315,9 @@ def filter_pip_freeze_output(pip_stdout: str): def strip_ref(line: str): # remove erroneous conda build paths that will break pip install - return line.split(" @ file:", 1)[0].strip() + # Keep a CRLF line ending. The caller splits on "\n", so the "\r" stays on the line. + line_ending = "\r" if line.endswith("\r") else "" + return line.split(" @ file:", 1)[0].strip() + line_ending def exclude(line: str): diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 0bfaf26a4..7324ab0f5 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -3488,3 +3488,14 @@ def test_write_notebook_manifest_keeps_environment_file_bytes(tmp_path: Path, wi write_manifest(".", "notebook.ipynb", environment, str(tmp_path)) assert (tmp_path / "requirements.txt").read_bytes() == to_bytes(environment.contents) + + +def test_write_api_manifest_checksum_matches_crlf_requirements_file(tmp_path: Path): + (tmp_path / "app.py").write_text("from shiny import App\n") + (tmp_path / "requirements.txt").write_bytes(b"shiny==1.0.0\r\nnumpy\r\n") + environment = Environment.create_python_environment(str(tmp_path)) + + write_api_manifest_json(str(tmp_path), "app:app", environment, AppModes.PYTHON_SHINY, [], []) + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["files"]["requirements.txt"]["checksum"] == file_checksum(tmp_path / "requirements.txt") diff --git a/tests/test_environment.py b/tests/test_environment.py index 43fb7f57d..2b037f0f3 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -237,7 +237,7 @@ def test_pyproject_dependencies(tmp_path): def test_requirements_file_keeps_crlf_line_endings(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes(b"numpy\r\npandas\r\n") - env = detect_environment(str(tmp_path)) + env = Environment.create_python_environment(str(tmp_path)) assert env.contents == "numpy\r\npandas\r\n" From e548491fe6e253e93ac6255ed7f4c81894ad50af Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 17:44:37 -0700 Subject: [PATCH 4/6] move import --- tests/test_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 7324ab0f5..8b0c18a33 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -13,6 +13,7 @@ import pytest +import rsconnect.bundle from rsconnect.bundle import ( Manifest, _default_title, @@ -57,7 +58,6 @@ from rsconnect.environment import Environment, PackageInstaller from rsconnect.exception import RSConnectException from rsconnect.models import AppModes -import rsconnect.bundle from .utils import get_dir, get_manifest_path From ab541b6e2cc1ade8012d4aa8e18065a5ba015620 Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 17:50:13 -0700 Subject: [PATCH 5/6] add write-manifest CLI test for CRLF requirements checksum --- tests/test_main.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index a0f012205..50a16bb1c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,7 @@ from rsconnect import VERSION from rsconnect.api import RSConnectClient, RSConnectServer +from rsconnect.bundle import file_checksum from rsconnect.json_web_token import SECRET_KEY_ENV from rsconnect.log import console_logger, logger as rs_logger from rsconnect.main import cli, env_management_callback, make_notebook_html_bundle @@ -2149,6 +2150,19 @@ def test_exclude_renv_omits_r_dependencies(self, tmp_path): assert "packages" not in manifest +class TestWriteManifestCRLF: + def test_checksum_matches_crlf_requirements_file(self, tmp_path): + (tmp_path / "app.py").write_text("from shiny import App\n") + (tmp_path / "requirements.txt").write_bytes(b"shiny==1.0.0\r\nnumpy\r\n") + + runner = CliRunner() + result = runner.invoke(cli, ["write-manifest", "shiny", str(tmp_path)]) + assert result.exit_code == 0, result.output + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["files"]["requirements.txt"]["checksum"] == file_checksum(tmp_path / "requirements.txt") + + class TestDefaultServer: def test_list_shows_default_marker(self, tmp_path): from rsconnect.metadata import ServerStore From 13a125874bac690ff62a816abbfc12f129863b49 Mon Sep 17 00:00:00 2001 From: Kara Woo Date: Tue, 29 Sep 2026 19:34:11 -0700 Subject: [PATCH 6/6] fix tests for windows --- tests/test_bundle.py | 5 +++-- tests/test_environment.py | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index 8b0c18a33..15d915480 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -119,8 +119,9 @@ def test_make_notebook_source_bundle1(self): ], ) + # Compare to the file on disk. On Windows, git can check out the fixture with CRLF line endings. reqs = tar.extractfile("requirements.txt").read() - self.assertEqual(reqs, b"numpy\npandas\nmatplotlib\n") + self.assertEqual(reqs, Path(directory, "requirements.txt").read_bytes()) manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) @@ -160,7 +161,7 @@ def test_make_notebook_source_bundle1(self): "dummy.ipynb": { "checksum": ipynb_hash, }, - "requirements.txt": {"checksum": "5f2a5e862fe7afe3def4a57bb5cfb214"}, + "requirements.txt": {"checksum": file_checksum(Path(directory, "requirements.txt"))}, }, }, ) diff --git a/tests/test_environment.py b/tests/test_environment.py index 2b037f0f3..cbf364b9a 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -45,7 +45,8 @@ def test_get_default_locale(self): self.assertEqual(get_default_locale(lambda: (None, None)), "") def test_file(self): - result = Environment.create_python_environment(get_dir("pip1")) + directory = get_dir("pip1") + result = Environment.create_python_environment(directory) self.assertTrue(version_re.match(result.pip)) @@ -54,7 +55,7 @@ def test_file(self): expected = Environment.from_dict( dict( - contents="numpy\npandas\nmatplotlib\n", + contents=Path(directory, "requirements.txt").read_bytes().decode("utf-8"), filename="requirements.txt", locale=result.locale, package_manager="pip", @@ -73,7 +74,7 @@ def test_requirements_override(self): shutil.copytree(get_dir("pip1"), project_dir) os.makedirs(os.path.join(project_dir, "alt"), exist_ok=True) custom_requirements = os.path.join(project_dir, "alt", "custom.txt") - with open(custom_requirements, "w") as f: + with open(custom_requirements, "w", newline="\n") as f: f.write("foo==1.0\nbar>=2.0\nrsconnect==0.1\n") result = Environment.create_python_environment(