Skip to content

Commit 844bb49

Browse files
authored
feat: --requirements-file option (#748)
1 parent e0d03d6 commit 844bb49

File tree

7 files changed

+221
-75
lines changed

7 files changed

+221
-75
lines changed

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
dependencies installed by connect to run the deployed content
1414
- `rsconnect content venv` command recreates a local python environment
1515
equal to the one used by connect to run the content.
16+
- Added `--requirements-file` option on deploy and write-manifest commands to
17+
supply an explicit requirements file instead of detecting the environment.
1618

1719

1820
## [1.28.2] - 2025-12-05

rsconnect/actions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,8 @@ def deploy_app(
382382

383383
environment = Environment.create_python_environment(
384384
directory, # pyright: ignore
385-
force_generate,
386-
python,
385+
requirements_file="requirements.txt" if not force_generate else None,
386+
python=python,
387387
)
388388

389389
# At this point, kwargs has a lot of things, but we can need to prune it down to just the things that

rsconnect/environment.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def from_dict(
114114
def create_python_environment(
115115
cls,
116116
directory: str,
117-
force_generate: bool = False,
117+
requirements_file: typing.Optional[str] = "requirements.txt",
118118
python: typing.Optional[str] = None,
119119
override_python_version: typing.Optional[str] = None,
120120
app_file: typing.Optional[str] = None,
@@ -125,8 +125,8 @@ def create_python_environment(
125125
If no Python executable is provided, the current system Python executable is used.
126126
127127
:param directory: the project directory to inspect.
128-
:param force_generate: force generating "requirements.txt" to snapshot the environment
129-
packages even if it already exists.
128+
:param requirements_file: requirements file name relative to the project directory. If None,
129+
capture the environment via pip freeze.
130130
:param python: the Python executable of the environment to use for inspection.
131131
:param override_python_version: the Python version required by the project.
132132
:param app_file: the main application file to use for inspection.
@@ -138,9 +138,8 @@ def create_python_environment(
138138
else:
139139
module_file = app_file
140140

141-
# click.secho(' Deploying %s to server "%s"' % (directory, connect_server.url))
142141
_warn_on_ignored_manifest(directory)
143-
_warn_if_no_requirements_file(directory)
142+
_warn_if_no_requirements_file(directory, requirements_file)
144143
_warn_if_environment_directory(directory)
145144

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

165164
# with cli_feedback("Inspecting Python environment"):
166-
environment = cls._get_python_env_info(module_file, python, force_generate)
165+
environment = cls._get_python_env_info(module_file, python, requirements_file=requirements_file)
167166
environment.python_version_requirement = python_version_requirement
168167

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

184-
if force_generate:
183+
if requirements_file is None:
185184
_warn_on_ignored_requirements(directory, environment.filename)
186185

187186
return environment
188187

189188
@classmethod
190189
def _get_python_env_info(
191-
cls, file_name: str, python: typing.Optional[str], force_generate: bool = False
190+
cls,
191+
file_name: str,
192+
python: typing.Optional[str],
193+
requirements_file: typing.Optional[str] = "requirements.txt",
192194
) -> "Environment":
193195
"""
194196
Gathers the python and environment information relating to the specified file
195197
with an eye to deploy it.
196198
197199
:param file_name: the primary file being deployed.
198200
:param python: the optional name of a Python executable.
199-
:param force_generate: force generating "requirements.txt" or "environment.yml",
200-
even if it already exists.
201+
:param requirements_file: which requirements file to read. If None, generate via pip freeze.
201202
:return: information about the version of Python in use plus some environmental
202203
stuff.
203204
"""
204205
python = which_python(python)
205206
logger.debug("Python: %s" % python)
206-
environment = cls._inspect_environment(python, os.path.dirname(file_name), force_generate=force_generate)
207+
environment = cls._inspect_environment(python, os.path.dirname(file_name), requirements_file=requirements_file)
207208
if environment.error:
208209
raise RSConnectException(environment.error)
209210
logger.debug("Python: %s" % python)
@@ -215,21 +216,16 @@ def _inspect_environment(
215216
cls,
216217
python: str,
217218
directory: str,
218-
force_generate: bool = False,
219+
requirements_file: typing.Optional[str] = "requirements.txt",
219220
check_output: typing.Callable[..., bytes] = subprocess.check_output,
220221
) -> "Environment":
221222
"""Run the environment inspector using the specified python binary.
222223
223224
Returns a dictionary of information about the environment,
224225
or containing an "error" field if an error occurred.
225226
"""
226-
flags: typing.List[str] = []
227-
if force_generate:
228-
flags.append("f")
229-
230227
args = [python, "-m", "rsconnect.subprocesses.inspect_environment"]
231-
if flags:
232-
args.append("-" + "".join(flags))
228+
args.extend(["--requirements-file", requirements_file or "none"])
233229
args.append(directory)
234230

235231
try:
@@ -321,17 +317,28 @@ def _warn_on_ignored_manifest(directory: str) -> None:
321317
)
322318

323319

324-
def _warn_if_no_requirements_file(directory: str) -> None:
320+
def _warn_if_no_requirements_file(directory: str, requirements_file: typing.Optional[str]) -> None:
325321
"""
326-
Checks for the existence of a file called requirements.txt in the given directory.
327-
If it's not there, a warning will be printed.
322+
Check that a requirements file exists, and that it lives inside the deployment directory.
328323
329324
:param directory: the directory to check in.
325+
:param requirements_file: the name of the requirements file, or None to skip the check.
330326
"""
331-
if not os.path.exists(os.path.join(directory, "requirements.txt")):
327+
if requirements_file is None:
328+
return
329+
330+
directory_path = pathlib.Path(directory)
331+
requirements_file_path = directory_path / pathlib.Path(requirements_file)
332+
if directory_path not in requirements_file_path.parents:
333+
click.secho(
334+
" Warning: The requirements file '%s' is outside of the deployment directory.\n" % requirements_file,
335+
fg="red",
336+
)
337+
338+
if not requirements_file_path.exists():
332339
click.secho(
333340
" Warning: Capturing the environment using 'pip freeze'.\n"
334-
" Consider creating a requirements.txt file instead.",
341+
" Consider creating a %s file instead." % requirements_file,
335342
fg="yellow",
336343
)
337344

0 commit comments

Comments
 (0)