diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c96e78b3..0e4fff6f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - ?? +### Added + +- Introduced `--package-manager=uv|pip` option to enforce a specific package manager + to be used when deploying content. When omitted, the server will decide. + ## [1.27.1] - 2025-08-12 ### Fixed diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 274b7ee8..cc781eaf 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -121,6 +121,11 @@ class ManifestDataPythonPackageManager(TypedDict): name: str version: str package_file: str + # When set, hints server how to perform installs. + # If True, server may perform installs using `uv`. + # If False, server should not use `uv`. + # If omitted, behavior is server-driven (migration default). + allow_uv: NotRequired[bool] class ManifestData(TypedDict): @@ -137,7 +142,6 @@ class ManifestData(TypedDict): class Manifest: def __init__( self, - *, version: Optional[int] = None, environment: Optional[Environment] = None, app_mode: Optional[AppMode] = None, @@ -185,15 +189,19 @@ def __init__( self.data["metadata"]["content_category"] = "site" if environment: - package_manager = environment.package_manager - self.data["python"] = { - "version": environment.python, - "package_manager": { - "name": package_manager, - "version": getattr(environment, package_manager), - "package_file": environment.filename, - }, + pm_name = environment.package_manager + pm_version_value = getattr(environment, pm_name, None) + if pm_version_value is None: + # Fallback: use pip version if available; otherwise empty string + pm_version_value = getattr(environment, "pip", "") + pm: ManifestDataPythonPackageManager = { + "name": pm_name, + "version": pm_version_value, + "package_file": environment.filename, } + if getattr(environment, "package_manager_allow_uv", None) is not None: + pm["allow_uv"] = typing.cast(bool, environment.package_manager_allow_uv) + self.data["python"] = {"version": environment.python, "package_manager": pm} if environment.python_version_requirement: # If the environment has a python version requirement, @@ -585,7 +593,13 @@ def make_notebook_source_bundle( nb_name = basename(file) manifest = make_source_manifest( - AppModes.JUPYTER_NOTEBOOK, environment, nb_name, None, image, env_management_py, env_management_r + AppModes.JUPYTER_NOTEBOOK, + environment, + nb_name, + None, + image, + env_management_py, + env_management_r, ) if hide_all_input: if "jupyter" not in manifest: @@ -884,7 +898,13 @@ def make_api_manifest( relevant_files = create_file_list(directory, extra_files, excludes) manifest = make_source_manifest( - app_mode, environment, entry_point, None, image, env_management_py, env_management_r + app_mode, + environment, + entry_point, + None, + image, + env_management_py, + env_management_r, ) manifest_add_buffer(manifest, environment.filename, environment.contents) @@ -1657,7 +1677,13 @@ def write_notebook_manifest_json( raise RSConnectException('Could not determine the app mode from "%s"; please specify one.' % extension) manifest_data = make_source_manifest( - app_mode, environment, file_name, None, image, env_management_py, env_management_r + app_mode, + environment, + file_name, + None, + image, + env_management_py, + env_management_r, ) if hide_all_input or hide_tagged_input: if "jupyter" not in manifest_data: @@ -1913,7 +1939,15 @@ def write_api_manifest_json( """ extra_files = validate_extra_files(directory, extra_files) manifest, _ = make_api_manifest( - directory, entry_point, app_mode, environment, extra_files, excludes, image, env_management_py, env_management_r + directory, + entry_point, + app_mode, + environment, + extra_files, + excludes, + image, + env_management_py, + env_management_r, ) manifest_path = join(directory, "manifest.json") @@ -1998,6 +2032,8 @@ def write_quarto_manifest_json( extra_files, excludes, image, + env_management_py, + env_management_r, ) base_dir = file_or_directory diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 35280fef..13826d8c 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -47,6 +47,9 @@ def __init__( # Fields that are not loaded from the environment subprocess self.python_version_requirement = python_version_requirement self.python_interpreter = python_interpreter + # Optional override of server install behavior. If None, server-driven + # default is used. + self.package_manager_allow_uv: typing.Optional[bool] = None def __getattr__(self, name: str) -> typing.Any: # We directly proxy the attributes of the EnvironmentData object @@ -56,7 +59,7 @@ def __getattr__(self, name: str) -> typing.Any: def __setattr__(self, name: str, value: typing.Any) -> None: if name in self.DATA_FIELDS: # proxy the attribute to the underlying EnvironmentData object - self._data._replace(**{name: value}) + self._data = self._data._replace(**{name: value}) else: super().__setattr__(name, value) @@ -101,6 +104,7 @@ def create_python_environment( python: typing.Optional[str] = None, override_python_version: typing.Optional[str] = None, app_file: typing.Optional[str] = None, + package_manager: typing.Optional[str] = None, ) -> "Environment": """Given a project directory and a Python executable, return Environment information. @@ -153,6 +157,14 @@ def create_python_environment( # that didn't support environment.python.requires environment.python = override_python_version + if package_manager is not None: + if package_manager not in ("pip", "uv"): + raise RSConnectException("Unsupported package manager: %s" % package_manager) + # Override the package manager name recorded by inspector + environment.package_manager = package_manager # type: ignore[attr-defined] + # Derive allow_uv from selection + environment.package_manager_allow_uv = True if package_manager == "uv" else False + if force_generate: _warn_on_ignored_requirements(directory, environment.filename) diff --git a/rsconnect/main.py b/rsconnect/main.py index 357d2b9a..5544d11f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -889,6 +889,11 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.option("--hide-all-input", is_flag=True, default=False, help="Hide all input cells when rendering output") @click.option( "--hide-tagged-input", is_flag=True, default=False, help="Hide input code cells with the 'hide_input' tag" @@ -928,6 +933,7 @@ def deploy_notebook( env_management_r: Optional[bool], draft: bool, no_verify: bool = False, + package_manager: Optional[str] = None, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -944,6 +950,7 @@ def deploy_notebook( force_generate=force_generate, python=python, override_python_version=override_python_version, + package_manager=package_manager, ) if force_generate: @@ -1043,6 +1050,11 @@ def deploy_notebook( is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( "extra_files", @@ -1079,12 +1091,17 @@ def deploy_voila( no_verify: bool, draft: bool = False, connect_server: Optional[api.RSConnectServer] = None, # TODO: This appears to be unused + package_manager: Optional[str] = None, ): set_verbosity(verbose) output_params(ctx, locals().items()) app_mode = AppModes.JUPYTER_VOILA environment = Environment.create_python_environment( - path if isdir(path) else dirname(path), force_generate, python, override_python_version + path if isdir(path) else dirname(path), + force_generate, + python, + override_python_version, + package_manager=package_manager, ) ce = RSConnectExecutor( @@ -1254,6 +1271,11 @@ def deploy_manifest( is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.argument("file_or_directory", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( "extra_files", @@ -1288,6 +1310,7 @@ def deploy_quarto( env_management_r: bool, no_verify: bool, draft: bool, + package_manager: Optional[str], ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1620,6 +1643,13 @@ def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) + @click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=( + "Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven." + ), + ) @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( "extra_files", @@ -1659,12 +1689,17 @@ def deploy_app( secret: Optional[str], no_verify: bool, draft: bool, + package_manager: Optional[str], ): set_verbosity(verbose) entrypoint = validate_entry_point(entrypoint, directory) extra_files_list = validate_extra_files(directory, extra_files) environment = Environment.create_python_environment( - directory, force_generate, python, override_python_version=override_python_version + directory, + force_generate, + python, + override_python_version=override_python_version, + package_manager=package_manager, ) if app_mode == AppModes.PYTHON_SHINY: @@ -1804,6 +1839,11 @@ def write_manifest(): is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.option("--hide-all-input", is_flag=True, default=None, help="Hide all input cells when rendering output") @click.option("--hide-tagged-input", is_flag=True, default=None, help="Hide input code cells with the 'hide_input' tag") @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @@ -1830,6 +1870,7 @@ def write_manifest_notebook( env_management_r: Optional[bool], hide_all_input: Optional[bool] = None, hide_tagged_input: Optional[bool] = None, + package_manager: Optional[str] = None, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1849,6 +1890,7 @@ def write_manifest_notebook( python=python, override_python_version=override_python_version, app_file=file, + package_manager=package_manager, ) with cli_feedback("Creating manifest.json"): @@ -1902,6 +1944,11 @@ def write_manifest_notebook( is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( @@ -1944,6 +1991,7 @@ def write_manifest_voila( env_management_py: Optional[bool], env_management_r: Optional[bool], multi_notebook: bool, + package_manager: Optional[str] = None, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1961,6 +2009,7 @@ def write_manifest_voila( override_python_version=override_python_version, python=python, app_file=path, + package_manager=package_manager, ) environment_file_exists = exists(join(base_dir, environment.filename)) @@ -2037,6 +2086,11 @@ def write_manifest_voila( is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) +@click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=("Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven."), +) @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.argument("file_or_directory", type=click.Path(exists=True, dir_okay=True, file_okay=True)) @click.argument( @@ -2061,6 +2115,7 @@ def write_manifest_quarto( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + package_manager: Optional[str], ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -2085,7 +2140,11 @@ def write_manifest_quarto( if "jupyter" in engines: with cli_feedback("Inspecting Python environment"): environment = Environment.create_python_environment( - base_dir, force_generate=force_generate, override_python_version=override_python_version, python=python + base_dir, + force_generate=force_generate, + override_python_version=override_python_version, + python=python, + package_manager=package_manager, ) environment_file_exists = exists(join(base_dir, environment.filename)) @@ -2225,6 +2284,13 @@ def generate_write_manifest_python(app_mode: AppMode, alias: str, desc: Optional is_flag=True, help='Force generating "requirements.txt", even if it already exists.', ) + @click.option( + "--package-manager", + type=click.Choice(["pip", "uv"]), + help=( + "Select the Python package manager for installs in the manifest. " "By default, behavior is server-driven." + ), + ) @click.option("--verbose", "-v", "verbose", is_flag=True, help="Print detailed messages") @click.argument("directory", type=click.Path(exists=True, dir_okay=True, file_okay=False)) @click.argument( @@ -2249,6 +2315,7 @@ def manifest_writer( disable_env_management: Optional[bool], env_management_py: Optional[bool], env_management_r: Optional[bool], + package_manager: Optional[str], ): _write_framework_manifest( ctx, @@ -2265,6 +2332,7 @@ def manifest_writer( image, env_management_py, env_management_r, + package_manager=package_manager, ) return manifest_writer @@ -2296,6 +2364,7 @@ def _write_framework_manifest( image: Optional[str], env_management_py: Optional[bool], env_management_r: Optional[bool], + package_manager: Optional[str] = None, ): """ A common function for writing manifests for APIs as well as Dash, Streamlit, and Bokeh apps. diff --git a/tests/test_bundle.py b/tests/test_bundle.py index e300954e..669f529f 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -227,6 +227,65 @@ def test_make_notebook_source_bundle2(self): }, ) + def test_make_notebook_source_bundle_package_manager_uv(self): + directory = get_dir("pip1") + nb_path = join(directory, "dummy.ipynb") + environment = Environment.create_python_environment(directory, package_manager="uv") + + with make_notebook_source_bundle( + nb_path, + environment, + None, + hide_all_input=False, + hide_tagged_input=False, + image=None, + env_management_py=None, + env_management_r=None, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert manifest["python"]["package_manager"]["name"] == "uv" + assert manifest["python"]["package_manager"]["allow_uv"] is True + + def test_make_api_bundle_package_manager_pip(self): + from .utils import get_api_path + directory = get_api_path("stock-api-fastapi", "") + environment = Environment.create_python_environment(directory, package_manager="pip") + entrypoint = "app:app" + + with make_api_bundle( + directory, + entrypoint, + AppModes.PYTHON_FASTAPI, + environment, + extra_files=[], + excludes=[], + image=None, + env_management_py=None, + env_management_r=None, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert manifest["python"]["package_manager"]["name"] == "pip" + assert manifest["python"]["package_manager"].get("allow_uv") is False + + def test_default_package_manager_omits_allow_uv(self): + directory = get_dir("pip1") + nb_path = join(directory, "dummy.ipynb") + environment = Environment.create_python_environment(directory) + + with make_notebook_source_bundle( + nb_path, + environment, + None, + hide_all_input=False, + hide_tagged_input=False, + image=None, + env_management_py=None, + env_management_r=None, + ) as bundle, tarfile.open(mode="r:gz", fileobj=bundle) as tar: + manifest = json.loads(tar.extractfile("manifest.json").read().decode("utf-8")) + assert manifest["python"]["package_manager"]["name"] == "pip" + assert "allow_uv" not in manifest["python"]["package_manager"] + def test_make_quarto_source_bundle_from_simple_project(self): temp_proj = tempfile.mkdtemp()