diff --git a/rsconnect/environment.py b/rsconnect/environment.py index d2d2ca31..6b12b845 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -139,12 +139,13 @@ def create_python_environment( module_file = app_file _warn_on_ignored_manifest(directory) - _warn_if_no_requirements_file(directory, requirements_file) _warn_if_environment_directory(directory) python_version_requirement = pyproject.detect_python_version_requirement(directory) _warn_on_missing_python_version(python_version_requirement) + _check_requirements_file(directory, requirements_file) + if python is not None: # TODO: Remove the option in a future release logger.warning( @@ -317,9 +318,9 @@ def _warn_on_ignored_manifest(directory: str) -> None: ) -def _warn_if_no_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None: +def _check_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None: """ - Check that a requirements file exists, and that it lives inside the deployment directory. + Verify that a requirements file exists inside the deployment directory. :param directory: the directory to check in. :param requirements_file: the name of the requirements file, or None to skip the check. @@ -336,10 +337,11 @@ def _warn_if_no_requirements_file(directory: str, requirements_file: typing.Opti ) if not requirements_file_path.exists(): - click.secho( - " Warning: Capturing the environment using 'pip freeze'.\n" - " Consider creating a %s file instead." % requirements_file, - fg="yellow", + raise RSConnectException( + "The requirements file '%s' does not exist in '%s'.\n" + "Please create the file or specify a different file with --requirements-file.\n" + "To have the requirements file generated using pip freeze, pass --force-generate." + % (requirements_file, directory) ) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 8fcd5fd3..ce18ae72 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -74,11 +74,17 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require """ if requirements_file is None: + # --force-generate sets requirements_file to None result = pip_freeze() elif os.path.basename(requirements_file) == "uv.lock": result = uv_export(dirname, requirements_file) else: - result = output_file(dirname, requirements_file, "pip") or pip_freeze() + result = output_file(dirname, requirements_file, "pip") + if result is None: + raise EnvironmentException( + "The requirements file '%s' was not found in '%s'. " + "Please create it or use --force-generate to use pip freeze." % (requirements_file, dirname) + ) if result is not None: result["python"] = get_python_version() diff --git a/tests/test_bundle.py b/tests/test_bundle.py index a6f4022f..a9f88196 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -154,7 +154,7 @@ def test_make_notebook_source_bundle2(self): # the test environment. Don't do this in the production code, which # runs in the notebook server. We need the introspection to run in # the kernel environment and not the notebook server environment. - environment = Environment.create_python_environment(directory) + environment = Environment.create_python_environment(directory, requirements_file=None) with make_notebook_source_bundle( nb_path, @@ -314,7 +314,7 @@ def test_make_quarto_source_bundle_from_simple_project(self): # input file. create_fake_quarto_rendered_output(temp_proj, "myquarto") - environment = Environment.create_python_environment(temp_proj) + environment = Environment.create_python_environment(temp_proj, requirements_file=None) # mock the result of running of `quarto inspect ` inspect = { @@ -411,7 +411,7 @@ def test_make_quarto_source_bundle_from_complex_project(self): create_fake_quarto_rendered_output(site_dir, "index") create_fake_quarto_rendered_output(site_dir, "about") - environment = Environment.create_python_environment(temp_proj) + environment = Environment.create_python_environment(temp_proj, requirements_file=None) # mock the result of running of `quarto inspect ` inspect = { diff --git a/tests/test_environment.py b/tests/test_environment.py index 187e2a43..fcecb63f 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -11,6 +11,7 @@ from rsconnect.exception import RSConnectException from rsconnect.environment import Environment, which_python from rsconnect.subprocesses.inspect_environment import ( + EnvironmentException, detect_environment, filter_pip_freeze_output, get_default_locale, @@ -83,7 +84,7 @@ def test_requirements_override(self): assert result.source == "file" def test_pip_freeze(self): - result = Environment.create_python_environment(get_dir("pip2")) + result = Environment.create_python_environment(get_dir("pip2"), requirements_file=None) # these are the dependencies declared in our pyproject.toml self.assertIn("six", result.contents) @@ -223,22 +224,30 @@ def test_is_not_executable(self): class TestPythonVersionRequirements: def test_pyproject_toml(self): - env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "using_pyproject")) + env = Environment.create_python_environment( + os.path.join(TESTDATA, "python-project", "using_pyproject"), requirements_file=None + ) assert env.python_interpreter == sys.executable assert env.python_version_requirement == ">=3.8" def test_python_version(self): - env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "using_pyversion")) + env = Environment.create_python_environment( + os.path.join(TESTDATA, "python-project", "using_pyversion"), requirements_file=None + ) assert env.python_interpreter == sys.executable assert env.python_version_requirement == ">=3.8,<3.12" def test_all_of_them(self): - env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "allofthem")) + env = Environment.create_python_environment( + os.path.join(TESTDATA, "python-project", "allofthem"), requirements_file=None + ) assert env.python_interpreter == sys.executable assert env.python_version_requirement == ">=3.8,<3.12" def test_missing(self): - env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "empty")) + env = Environment.create_python_environment( + os.path.join(TESTDATA, "python-project", "empty"), requirements_file=None + ) assert env.python_interpreter == sys.executable assert env.python_version_requirement is None @@ -393,3 +402,44 @@ def test_python_interpreter(self): "Please use a .python-version file to force a specific interpreter version." ) assert result.python == current_python_version + + +class TestRequirementsFileRequired: + """When a requirements file is requested (the default), it must exist. + When force-generate is used (requirements_file=None), pip freeze is used regardless.""" + + def test_requirements_requested_and_file_exists(self): + """Default behavior with requirements.txt present succeeds and reads the file.""" + result = Environment.create_python_environment(get_dir("pip1")) + assert result.source == "file" + assert result.filename == "requirements.txt" + + def test_requirements_requested_and_file_missing(self): + """Default behavior without requirements.txt raises an error.""" + with pytest.raises(RSConnectException, match="does not exist"): + Environment.create_python_environment(get_dir("pip2")) + + def test_force_generate_and_file_exists(self): + """Force-generate ignores the existing requirements.txt and uses pip freeze.""" + result = Environment.create_python_environment(get_dir("pip1"), requirements_file=None) + assert result.source == "pip_freeze" + + def test_force_generate_and_file_missing(self): + """Force-generate works even without requirements.txt.""" + result = Environment.create_python_environment(get_dir("pip2"), requirements_file=None) + assert result.source == "pip_freeze" + + def test_detect_environment_requires_file(self): + """Subprocess-level: detect_environment errors when the file is missing.""" + with pytest.raises(EnvironmentException, match="was not found"): + detect_environment(get_dir("pip2"), requirements_file="requirements.txt") + + def test_detect_environment_reads_file(self): + """Subprocess-level: detect_environment reads the file when present.""" + result = detect_environment(get_dir("pip1"), requirements_file="requirements.txt") + assert result.source == "file" + + def test_detect_environment_force_generate(self): + """Subprocess-level: detect_environment uses pip freeze when requirements_file=None.""" + result = detect_environment(get_dir("pip2"), requirements_file=None) + assert result.source == "pip_freeze"