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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
dependencies installed by connect to run the deployed content
- `rsconnect content venv` command recreates a local python environment
equal to the one used by connect to run the content.
- Added `--requirements-file` option on deploy and write-manifest commands to
supply an explicit requirements file instead of detecting the environment.


## [1.28.2] - 2025-12-05
Expand Down
4 changes: 2 additions & 2 deletions rsconnect/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ def deploy_app(

environment = Environment.create_python_environment(
directory, # pyright: ignore
force_generate,
python,
requirements_file="requirements.txt" if not force_generate else None,
python=python,
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only used by vetiver-python package, which writes an application from scratch and deploys it using rsconnect. We can take for granted the requirements file is always "requirements.txt" here as it's driven by vetiver-python


# At this point, kwargs has a lot of things, but we can need to prune it down to just the things that
Expand Down
53 changes: 30 additions & 23 deletions rsconnect/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def from_dict(
def create_python_environment(
cls,
directory: str,
force_generate: bool = False,
requirements_file: typing.Optional[str] = "requirements.txt",
python: typing.Optional[str] = None,
override_python_version: typing.Optional[str] = None,
app_file: typing.Optional[str] = None,
Expand All @@ -125,8 +125,8 @@ def create_python_environment(
If no Python executable is provided, the current system Python executable is used.

:param directory: the project directory to inspect.
:param force_generate: force generating "requirements.txt" to snapshot the environment
packages even if it already exists.
:param requirements_file: requirements file name relative to the project directory. If None,
capture the environment via pip freeze.
:param python: the Python executable of the environment to use for inspection.
:param override_python_version: the Python version required by the project.
:param app_file: the main application file to use for inspection.
Expand All @@ -138,9 +138,8 @@ def create_python_environment(
else:
module_file = app_file

# click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url))
_warn_on_ignored_manifest(directory)
_warn_if_no_requirements_file(directory)
_warn_if_no_requirements_file(directory, requirements_file)
_warn_if_environment_directory(directory)

python_version_requirement = pyproject.detect_python_version_requirement(directory)
Expand All @@ -163,7 +162,7 @@ def create_python_environment(
python_version_requirement = f"=={override_python_version}"

# with cli_feedback("Inspecting Python environment"):
environment = cls._get_python_env_info(module_file, python, force_generate)
environment = cls._get_python_env_info(module_file, python, requirements_file=requirements_file)
environment.python_version_requirement = python_version_requirement

if override_python_version:
Expand All @@ -181,29 +180,31 @@ def create_python_environment(
# Derive allow_uv from selection
environment.package_manager_allow_uv = selected_package_manager is PackageInstaller.UV

if force_generate:
if requirements_file is None:
_warn_on_ignored_requirements(directory, environment.filename)

return environment

@classmethod
def _get_python_env_info(
cls, file_name: str, python: typing.Optional[str], force_generate: bool = False
cls,
file_name: str,
python: typing.Optional[str],
requirements_file: typing.Optional[str] = "requirements.txt",
) -> "Environment":
"""
Gathers the python and environment information relating to the specified file
with an eye to deploy it.

:param file_name: the primary file being deployed.
:param python: the optional name of a Python executable.
:param force_generate: force generating "requirements.txt" or "environment.yml",
even if it already exists.
:param requirements_file: which requirements file to read. If None, generate via pip freeze.
:return: information about the version of Python in use plus some environmental
stuff.
"""
python = which_python(python)
logger.debug("Python: %s" % python)
environment = cls._inspect_environment(python, os.path.dirname(file_name), force_generate=force_generate)
environment = cls._inspect_environment(python, os.path.dirname(file_name), requirements_file=requirements_file)
if environment.error:
raise RSConnectException(environment.error)
logger.debug("Python: %s" % python)
Expand All @@ -215,21 +216,16 @@ def _inspect_environment(
cls,
python: str,
directory: str,
force_generate: bool = False,
requirements_file: typing.Optional[str] = "requirements.txt",
check_output: typing.Callable[..., bytes] = subprocess.check_output,
) -> "Environment":
"""Run the environment inspector using the specified python binary.

Returns a dictionary of information about the environment,
or containing an "error" field if an error occurred.
"""
flags: typing.List[str] = []
if force_generate:
flags.append("f")

args = [python, "-m", "rsconnect.subprocesses.inspect_environment"]
if flags:
args.append("-" + "".join(flags))
args.extend(["--requirements-file", requirements_file or "none"])
args.append(directory)

try:
Expand Down Expand Up @@ -321,17 +317,28 @@ def _warn_on_ignored_manifest(directory: str) -> None:
)


def _warn_if_no_requirements_file(directory: str) -> None:
def _warn_if_no_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None:
"""
Checks for the existence of a file called requirements.txt in the given directory.
If it's not there, a warning will be printed.
Check that a requirements file exists, and that it lives 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.
"""
if not os.path.exists(os.path.join(directory, "requirements.txt")):
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:
click.secho(
" Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file,
fg="red",
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the user can now provide a requirements file, the used requirements file might be "../../../something.txt", we want to make sure the user has clear that's not meant to happen.


if not requirements_file_path.exists():
click.secho(
" Warning: Capturing the environment using 'pip freeze'.\n"
" Consider creating a requirements.txt file instead.",
" Consider creating a %s file instead." % requirements_file,
Comment on lines 340 to +341
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial impulse was that if a user explicitly passes in a requirements file path and we can't find a file there, we should emit an error, not a warning.

But I realize we don't have any way to know whether the path we have is one that was explicitly provided or the default from the user, so I guess I'm just noting my weird feeling here.

fg="yellow",
)

Expand Down
Loading