Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 7 additions & 6 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions rsconnect/subprocesses/inspect_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -314,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):
Expand Down
62 changes: 60 additions & 2 deletions tests/test_bundle.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import builtins
import io
import json
import os
Expand All @@ -7,10 +8,12 @@
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

import rsconnect.bundle
from rsconnect.bundle import (
Manifest,
_default_title,
Expand All @@ -37,6 +40,7 @@
make_tensorflow_manifest,
make_voila_bundle,
default_title_from_bundle,
file_checksum,
open_bundle,
read_bundle_app_mode,
read_bundle_manifest,
Expand All @@ -45,6 +49,9 @@
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
Expand Down Expand Up @@ -112,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"))

Expand Down Expand Up @@ -153,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"))},
},
},
)
Expand Down Expand Up @@ -3442,3 +3450,53 @@ 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"

Comment thread
karawoo marked this conversation as resolved.

@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)


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")
40 changes: 31 additions & 9 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tempfile
import shutil
import subprocess
from pathlib import Path
from unittest import TestCase
from unittest import mock

Expand Down Expand Up @@ -44,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))

Expand All @@ -53,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",
Expand All @@ -72,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(
Expand Down Expand Up @@ -148,22 +150,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"
Expand Down Expand Up @@ -221,6 +235,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 = Environment.create_python_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()
Expand Down
14 changes: 14 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading