diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 85bd1037..77ce406d 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -143,7 +143,7 @@ def create_python_environment( python_version_requirement = pyproject.detect_python_version_requirement(directory) _warn_on_missing_python_version(python_version_requirement) - _check_requirements_file(directory, requirements_file) + requirements_file = _check_requirements_file(directory, requirements_file) if python is not None: # TODO: Remove the option in a future release @@ -314,7 +314,7 @@ def _warn_on_ignored_manifest(directory: str) -> None: warn_user(" Warning: the existing manifest.json file will not be used or considered.") -def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None: +def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> typing.Optional[str]: """ Verify that a requirements file exists inside the deployment directory. @@ -324,13 +324,14 @@ def _check_requirements_file(directory: str, requirements_file: typing.Optional[ if requirements_file is None: return - directory_path = pathlib.Path(directory) - requirements_file_path = directory_path / pathlib.Path(requirements_file) - if directory_path not in requirements_file_path.parents: - warn_user( - " Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file, - fg="red", - ) + directory_path = pathlib.Path(directory).resolve() + requirements_file_path = (directory_path / requirements_file).resolve() + try: + relative_path = requirements_file_path.relative_to(directory_path) + except ValueError: + raise RSConnectException( + "The requirements file '%s' is outside of the deployment directory '%s'." % (requirements_file, directory) + ) from None if not requirements_file_path.exists(): raise RSConnectException( @@ -339,6 +340,7 @@ def _check_requirements_file(directory: str, requirements_file: typing.Optional[ "To have the requirements file generated using pip freeze, pass --force-generate." % (requirements_file, directory) ) + return str(relative_path) def _warn_if_environment_directory(directory: typing.Union[str, pathlib.Path]) -> None: diff --git a/tests/test_main.py b/tests/test_main.py index 94a59c2f..f55589fb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2169,18 +2169,50 @@ def test_force_generate_requirements(self, tmp_path, existing_requirements): assert requirements.read_text().startswith("# requirements.txt generated by rsconnect-python") assert manifest["files"]["requirements.txt"]["checksum"] == file_checksum(requirements) - def test_explicit_requirements_file(self, tmp_path): + @pytest.mark.parametrize("path_kind", ["relative", "absolute", "normalized"]) + def test_explicit_requirements_file(self, tmp_path, path_kind): (tmp_path / "app.py").write_text("app = object()\n") - requirements = tmp_path / "custom-requirements.txt" + (tmp_path / "dependencies").mkdir() + requirements = tmp_path / "dependencies" / "custom-requirements.txt" requirements.write_text("example-package==1.0\n") + relative_path = os.path.join("dependencies", requirements.name) + requirement_paths = { + "relative": relative_path, + "absolute": str(requirements), + "normalized": os.path.join("dependencies", "..", relative_path), + } runner = CliRunner() - result = runner.invoke(cli, ["write-manifest", "api", "-r", requirements.name, str(tmp_path)]) + result = runner.invoke(cli, ["write-manifest", "api", "-r", requirement_paths[path_kind], str(tmp_path)]) assert result.exit_code == 0, result.output manifest = json.loads((tmp_path / "manifest.json").read_text()) assert requirements.read_text() == "example-package==1.0\n" - assert manifest["files"][requirements.name]["checksum"] == file_checksum(requirements) + assert manifest["python"]["package_manager"]["package_file"] == relative_path + assert manifest["files"][relative_path]["checksum"] == file_checksum(requirements) + assert set(manifest["files"]) == {"app.py", relative_path} + assert f"{relative_path} already exists and will not be overwritten" in result.output + + @pytest.mark.parametrize("path_kind", ["absolute", "relative", "symlink"]) + def test_requirements_file_outside_project_is_rejected(self, tmp_path, path_kind): + project = tmp_path / "project" + project.mkdir() + (project / "app.py").write_text("app = object()\n") + outside = tmp_path / "requirements.txt" + outside.write_text("example-package==1.0\n") + if path_kind == "symlink": + (project / "linked.txt").symlink_to(outside) + requirement_paths = { + "absolute": str(outside), + "relative": os.path.join("..", outside.name), + "symlink": "linked.txt", + } + + result = CliRunner().invoke(cli, ["write-manifest", "api", "-r", requirement_paths[path_kind], str(project)]) + + assert result.exit_code != 0 + assert "outside of the deployment directory" in result.output + assert not (project / "manifest.json").exists() class TestWriteManifestExcludeRenv: