From 4d6b2ea55288fcd1dfd93770d804412b3055faab Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 3 Mar 2026 11:41:43 +0100 Subject: [PATCH 1/3] enforce requirements.txt existing --- rsconnect/environment.py | 15 ++--- rsconnect/subprocesses/inspect_environment.py | 7 ++- tests/test_bundle.py | 6 +- tests/test_environment.py | 60 +++++++++++++++++-- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/rsconnect/environment.py b/rsconnect/environment.py index d2d2ca31..5d8bebc3 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,10 @@ 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 use pip freeze instead, pass --force-generate." % (requirements_file, directory) ) diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 8fcd5fd3..a51e59ff 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -78,7 +78,12 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require 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" From 28523cb2fd6a5dd7df559045362af978901feed7 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 3 Mar 2026 15:40:53 +0100 Subject: [PATCH 2/3] Update rsconnect/environment.py Co-authored-by: Taylor Steinberg --- rsconnect/environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 5d8bebc3..0b5ad73c 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -340,7 +340,7 @@ def _check_requirements_file(directory: str, requirements_file: typing.Optional[ 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 use pip freeze instead, pass --force-generate." % (requirements_file, directory) + "To have the requirements file generated using pip freeze, pass --force-generate." % (requirements_file, directory) ) From 7681d26b7c03ff7029139142e83f78fbcc270301 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 3 Mar 2026 15:51:30 +0100 Subject: [PATCH 3/3] address some review comments --- rsconnect/environment.py | 3 ++- rsconnect/subprocesses/inspect_environment.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 0b5ad73c..6b12b845 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -340,7 +340,8 @@ def _check_requirements_file(directory: str, requirements_file: typing.Optional[ 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) + "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 a51e59ff..ce18ae72 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -74,6 +74,7 @@ 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)