From bb241f14a8d719adb273199e109ca93c799cdd1b Mon Sep 17 00:00:00 2001 From: Taylor Steinberg Date: Wed, 28 Oct 2026 11:58:08 -0400 Subject: [PATCH] fix: accept UTF-8 BOM in pyproject.toml --- rsconnect/pyproject.py | 4 ++-- rsconnect/subprocesses/inspect_environment.py | 2 +- tests/test_environment.py | 13 ++++++++++-- tests/test_pyproject.py | 8 ++++++++ tests/test_write_manifest_pyproject.py | 20 ++++++++++++++----- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index d0b591d73..ddf77fec1 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -97,7 +97,7 @@ def parse_pyproject_python_requires(pyproject_file: pathlib.Path) -> typing.Opti Returns None if the field is not found. """ - content = pyproject_file.read_text() + content = pyproject_file.read_text(encoding="utf-8-sig") pyproject = tomllib.loads(content) return pyproject.get("project", {}).get("requires-python", None) @@ -195,7 +195,7 @@ def read_tool_rsconnect(pyproject_file: pathlib.Path) -> typing.Mapping[str, typ missing or when required ``app_mode`` / ``entrypoint`` fields are absent or not non-empty strings. """ - content = pyproject_file.read_text() + content = pyproject_file.read_text(encoding="utf-8-sig") pyproject = tomllib.loads(content) tool = pyproject.get("tool") diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index ab0f598ac..ca257e398 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -281,7 +281,7 @@ def pyproject_dependencies(dirname: str, pyproject_filename: str): raise EnvironmentException(f"pyproject.toml not found: {pyproject_filename}") try: - with open(pyproject_path, "r", encoding="utf-8") as f: + with open(pyproject_path, "r", encoding="utf-8-sig") as f: pyproject = tomllib.loads(f.read()) except Exception as exception: raise EnvironmentException(f"Error reading {pyproject_filename}: {exception}") diff --git a/tests/test_environment.py b/tests/test_environment.py index cbf364b9a..f01430f46 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -208,7 +208,8 @@ def test_uv_lock_export(tmp_path): assert env.package_manager == "uv" -def test_pyproject_dependencies(tmp_path): +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) +def test_pyproject_dependencies(tmp_path, encoding): project_dir = tmp_path / "project" project_dir.mkdir() (project_dir / "pyproject.toml").write_text( @@ -220,7 +221,7 @@ def test_pyproject_dependencies(tmp_path): " 'annotated-types>=0.7.0',\n" " 'rsconnect-python==1.0.0',\n" "]\n", - encoding="utf-8", + encoding=encoding, ) env = detect_environment(str(project_dir), requirements_file="pyproject.toml") @@ -235,6 +236,14 @@ def test_pyproject_dependencies(tmp_path): assert env.package_manager == "pip" +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) +def test_pyproject_dependencies_invalid_toml(tmp_path, encoding): + (tmp_path / "pyproject.toml").write_text("[project\ndependencies =", encoding=encoding) + + with pytest.raises(EnvironmentException, match="Error reading pyproject.toml"): + detect_environment(str(tmp_path), requirements_file="pyproject.toml") + + def test_requirements_file_keeps_crlf_line_endings(tmp_path: Path): (tmp_path / "requirements.txt").write_bytes(b"numpy\r\npandas\r\n") diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 2863de0ce..a43bda965 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -99,6 +99,14 @@ def test_pyprojecttoml_python_requires(project_dir, expected): assert parse_pyproject_python_requires(pyproject_file) == expected +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) +def test_pyprojecttoml_python_requires_encoding(tmp_path, encoding): + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text('[project]\nname = "café"\nrequires-python = ">=3.8"\n', encoding=encoding) + + assert parse_pyproject_python_requires(pyproject_file) == ">=3.8" + + @pytest.mark.parametrize( "project_dir, expected", [ diff --git a/tests/test_write_manifest_pyproject.py b/tests/test_write_manifest_pyproject.py index d809f3ab9..e51d0391b 100644 --- a/tests/test_write_manifest_pyproject.py +++ b/tests/test_write_manifest_pyproject.py @@ -352,12 +352,19 @@ def test_write_manifest_pyproject_never_rewrites_explicit_requirements_source( pass -def test_write_manifest_pyproject_unmocked_end_to_end(runner: CliRunner, project_dir: pathlib.Path): +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) +@pytest.mark.parametrize("command", ["pyproject", "api"]) +def test_write_manifest_pyproject_unmocked_end_to_end( + runner: CliRunner, project_dir: pathlib.Path, encoding: str, command: str +): """Real environment inspection: default config must yield a deployable manifest.""" - _write_pyproject(project_dir, _REQ_PYPROJECT) + (project_dir / "pyproject.toml").write_text(textwrap.dedent(_REQ_PYPROJECT), encoding=encoding) (project_dir / "app.py").write_text("app = None\n") - result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) + args = ["write-manifest", command] + if command == "api": + args.extend(["-r", "pyproject.toml"]) + result = runner.invoke(cli, [*args, str(project_dir)]) assert result.exit_code == 0, result.output manifest = _read_manifest(project_dir) @@ -514,8 +521,11 @@ def test_write_manifest_pyproject_errors_on_missing_pyproject(runner: CliRunner, 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 =") +@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig"]) +def test_write_manifest_pyproject_errors_on_malformed_pyproject( + runner: CliRunner, project_dir: pathlib.Path, encoding: str +): + (project_dir / "pyproject.toml").write_text("[tool.rsconnect\napp_mode =", encoding=encoding) result = runner.invoke(cli, ["write-manifest", "pyproject", str(project_dir)]) assert result.exit_code != 0 assert "could not be parsed" in result.output