From 0762791d4070cad4b8e05dc345566a5c32678ced Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 12 Dec 2025 17:03:50 +0100 Subject: [PATCH 1/9] Initial poc for lockfile --- rsconnect/actions_content.py | 5 +++ rsconnect/api.py | 8 +++++ rsconnect/main.py | 68 ++++++++++++++++++++++++++++++++++++ tests/test_main_content.py | 52 +++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) diff --git a/rsconnect/actions_content.py b/rsconnect/actions_content.py index 5b5ba1a2..a419e309 100644 --- a/rsconnect/actions_content.py +++ b/rsconnect/actions_content.py @@ -391,6 +391,11 @@ def download_bundle(connect_server: Union[RSConnectServer, SPCSConnectServer], g return client.download_bundle(guid_with_bundle.guid, guid_with_bundle.bundle_id) +def download_lockfile(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str): + with RSConnectClient(connect_server) as client: + return client.content_lockfile(guid) + + def get_content(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str | list[str]): """ :param guid: a single guid as a string or list of guids. diff --git a/rsconnect/api.py b/rsconnect/api.py index 1c5817ef..723c4005 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -457,6 +457,14 @@ def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse: response = self._server.handle_bad_response(response, is_httpresponse=True) return response + def content_lockfile(self, content_guid: str) -> HTTPResponse: + response = cast( + HTTPResponse, + self.get("v1/content/%s/lockfile" % content_guid, decode_response=False), + ) + response = self._server.handle_bad_response(response, is_httpresponse=True) + return response + def content_list(self, filters: Optional[Mapping[str, JsonData]] = None) -> list[ContentItemV1]: response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content", query_params=filters)) response = self._server.handle_bad_response(response) diff --git a/rsconnect/main.py b/rsconnect/main.py index 475c9af2..63646f86 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -55,6 +55,7 @@ build_remove_content, build_start, download_bundle, + download_lockfile, emit_build_log, get_content, search_content, @@ -2762,6 +2763,73 @@ def content_bundle_download( f.write(result.response_body) +@content.command( + name="get-lockfile", + short_help="Download a content item's lockfile.", +) +@server_args +@spcs_args +@click.option( + "--guid", + "-g", + required=True, + type=StrippedStringParamType(), + metavar="TEXT", + help="The GUID of a content item whose lockfile will be downloaded.", +) +@click.option( + "--output", + "-o", + type=click.Path(), + default="requirements.txt.lock", + show_default=True, + help="Defines the output location for the lockfile download.", +) +@click.option( + "--overwrite", + "-w", + is_flag=True, + help="Overwrite the output file if it already exists.", +) +@click.pass_context +def content_get_lockfile( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + guid: str, + output: str, + overwrite: bool, + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect content get-lockfile` requires a Posit Connect server.") + if exists(output) and not overwrite: + raise RSConnectException("The output file already exists: %s" % output) + + result = download_lockfile(ce.remote_server, guid) + if not isinstance(result.response_body, bytes): + raise RSConnectException("The response body must be bytes (not string or None).") + with open(output, "wb") as f: + f.write(result.response_body) + + @content.group(no_args_is_help=True, help="Build content on Posit Connect. Requires Connect >= 2021.11.1") def build(): pass diff --git a/tests/test_main_content.py b/tests/test_main_content.py index 64984dac..e34391fe 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -46,6 +46,12 @@ def register_content_endpoints(i: int, guid: str): + "}", adding_headers={"Content-Type": "application/json"}, ) + httpretty.register_uri( + httpretty.GET, + f"{connect_server}/__api__/v1/content/{guid}/lockfile", + body="package-a==1.2.3\n", + adding_headers={"Content-Type": "text/plain"}, + ) httpretty.register_uri( httpretty.GET, @@ -160,6 +166,52 @@ def test_content_download_bundle(self): manifest = json.loads(tgz.extractfile("manifest.json").read()) self.assertIn("metadata", manifest) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_content_get_lockfile(self): + register_uris(self.connect_server) + os.makedirs(TEMP_DIR, exist_ok=True) + runner = CliRunner() + output_path = f"{TEMP_DIR}/requirements.txt.lock" + args = [ + "content", + "get-lockfile", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + "-o", + output_path, + ] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = runner.invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + with open(output_path) as lockfile: + self.assertEqual(lockfile.read(), "package-a==1.2.3\n") + + args_exists = [ + "content", + "get-lockfile", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + "-o", + output_path, + ] + apply_common_args(args_exists, server=self.connect_server, key=self.api_key) + result_exists = runner.invoke(cli, args_exists) + self.assertNotEqual(result_exists.exit_code, 0, result_exists.output) + self.assertIn("already exists", result_exists.output) + + args_overwrite = [ + "content", + "get-lockfile", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + "-o", + output_path, + "-w", + ] + apply_common_args(args_overwrite, server=self.connect_server, key=self.api_key) + result_overwrite = runner.invoke(cli, args_overwrite) + self.assertEqual(result_overwrite.exit_code, 0, result_overwrite.output) + @httpretty.activate(verbose=True, allow_net_connect=False) def test_build(self): register_uris(self.connect_server) From 391d768cf0e1c95ba25356d281b1ae3a9e8a5382 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Mon, 15 Dec 2025 18:41:39 +0100 Subject: [PATCH 2/9] Improve messaging --- rsconnect/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 63646f86..5d90f7db 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2821,8 +2821,9 @@ def content_get_lockfile( if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): raise RSConnectException("`rsconnect content get-lockfile` requires a Posit Connect server.") if exists(output) and not overwrite: - raise RSConnectException("The output file already exists: %s" % output) + raise RSConnectException("The output file already exists: %s, maybe you want to --overwrite?" % output) + logger.info("Downloading %s for content %s" % (output, guid)) result = download_lockfile(ce.remote_server, guid) if not isinstance(result.response_body, bytes): raise RSConnectException("The response body must be bytes (not string or None).") From b34bc4b4a83cf098004cdbdcf1ae78efd2de7060 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 16 Dec 2025 17:52:07 +0100 Subject: [PATCH 3/9] Add content venv command --- pyproject.toml | 1 + requirements.txt | 1 + rsconnect/http_support.py | 11 ++++ rsconnect/main.py | 102 +++++++++++++++++++++++++++++++++++++ tests/test_main_content.py | 47 +++++++++++++++-- 5 files changed, 159 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b163ffcd..d4a5dd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.8" dependencies = [ "typing-extensions>=4.8.0", "pip>=10.0.0", + "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", diff --git a/requirements.txt b/requirements.txt index 722566f0..66c7a919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ wheel six>=1.14.0 click>=8.0.0 pip>=10.0.0 +uv>=0.9.0 semver>=2.0.0,<3.0.0 pyjwt>=2.4.0 black==24.3.0 diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index e0e97b38..3b981dab 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -206,6 +206,17 @@ def __init__( except json.decoder.JSONDecodeError: self.response_body + def getheader(self, name: str) -> Optional[str]: + """ + This method retrieves a specific header from the response. + + :param name: the name of the header to retrieve. + :return: the value of the header, or None if not present. + """ + if self._response is None: + return None + return self._response.getheader(name) + class HTTPServer(object): """ diff --git a/rsconnect/main.py b/rsconnect/main.py index 5d90f7db..970a5afc 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -6,6 +6,9 @@ import sys import textwrap import traceback +import shutil +import subprocess +import tempfile from functools import wraps from os.path import abspath, dirname, exists, isdir, join from typing import ( @@ -2831,6 +2834,105 @@ def content_get_lockfile( f.write(result.response_body) +@content.command( + name="venv", + short_help="Replicate a Python environment from Connect", +) +@server_args +@spcs_args +@click.option( + "--guid", + "-g", + type=StrippedStringParamType(), + metavar="TEXT", + help=( + "The GUID of a content item whose lockfile will be used to build the environment. " + "If omitted, rsconnect will try to auto-detect the last deployed GUID for the current server " + "from local deployment metadata." + ), +) +@click.argument("env_path", metavar="ENV_PATH", type=click.Path()) +@click.pass_context +def content_venv( + ctx: click.Context, + name: Optional[str], + server: Optional[str], + api_key: Optional[str], + snowflake_connection_name: Optional[str], + insecure: bool, + cacert: Optional[str], + guid: Optional[str], + env_path: str, + verbose: int, +): + set_verbosity(verbose) + output_params(ctx, locals().items()) + uv_path = shutil.which("uv") + if not uv_path: + raise RSConnectException("uv is required for `rsconnect content venv`. make sure it's available in your PATH and try again.") + + def _python_version_from_header(header: Optional[str]) -> str: + header = header or "" + *_ , version = header.split("python=", 1) + version = version.split(".")[:2] # major.minor + return ".".join(version) + + def _guid_for_current_server(server_url: str) -> Optional[str]: + for candidate in _get_names_to_check(os.getcwd()): + deployment = AppStore(candidate).get(server_url) + if deployment: + return deployment.get("app_guid") or deployment.get("app_id") + return None + + with cli_feedback("", stderr=True): + ce = RSConnectExecutor( + ctx=ctx, + name=name, + server=server, + api_key=api_key, + snowflake_connection_name=snowflake_connection_name, + insecure=insecure, + cacert=cacert, + logger=None, + ).validate_server() + if not isinstance(ce.remote_server, (RSConnectServer, SPCSConnectServer)): + raise RSConnectException("`rsconnect content venv` requires a Posit Connect server.") + + guid = guid or _guid_for_current_server(ce.remote_server.url) + if not guid: + raise RSConnectException( + "No GUID provided and none found for this server in local deployment metadata. " + "Provide --guid or deploy from this directory first." + ) + + result = download_lockfile(ce.remote_server, guid) + if not isinstance(result.response_body, bytes): + raise RSConnectException("The response body must be bytes (not string or None).") + + python_version = _python_version_from_header(result.getheader("Generated-By")) + with tempfile.NamedTemporaryFile("wb") as lockfile: + lockfile.write(result.response_body) + lockfile.flush() + + if not exists(env_path): + uv_venv_cmd = [uv_path, "venv"] + if python_version: + uv_venv_cmd.extend(["--python", python_version]) + uv_venv_cmd.append(env_path) + venv_result = subprocess.run(uv_venv_cmd, env=dict(os.environ, UV_PYTHON_DOWNLOADS="auto")) + if venv_result.returncode != 0: + raise RSConnectException("uv venv failed with exit code %d" % venv_result.returncode) + + logger.info("Syncing environment %s" % env_path) + result = subprocess.run([ + uv_path, "pip", "install", "--python", env_path, "-r", lockfile.name + ]) + if result.returncode != 0: + raise RSConnectException("uv pip install failed with exit code %d" % result.returncode) + + logger.info("Environment ready. Activate with: source %s/bin/activate" % env_path) + + @content.group(no_args_is_help=True, help="Build content on Posit Connect. Requires Connect >= 2021.11.1") def build(): pass diff --git a/tests/test_main_content.py b/tests/test_main_content.py index e34391fe..3a30d034 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -3,6 +3,8 @@ import shutil import tarfile import unittest +from unittest import mock +import sys import httpretty from click.testing import CliRunner @@ -49,8 +51,11 @@ def register_content_endpoints(i: int, guid: str): httpretty.register_uri( httpretty.GET, f"{connect_server}/__api__/v1/content/{guid}/lockfile", - body="package-a==1.2.3\n", - adding_headers={"Content-Type": "text/plain"}, + body="click==8.1.3\n", + adding_headers={ + "Content-Type": "text/plain", + "Generated-By": f"connect; python={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + }, ) httpretty.register_uri( @@ -184,7 +189,7 @@ def test_content_get_lockfile(self): result = runner.invoke(cli, args) self.assertEqual(result.exit_code, 0, result.output) with open(output_path) as lockfile: - self.assertEqual(lockfile.read(), "package-a==1.2.3\n") + self.assertEqual(lockfile.read(), "click==8.1.3\n", result.output) args_exists = [ "content", @@ -212,6 +217,42 @@ def test_content_get_lockfile(self): result_overwrite = runner.invoke(cli, args_overwrite) self.assertEqual(result_overwrite.exit_code, 0, result_overwrite.output) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_content_venv(self): + register_uris(self.connect_server) + env_path = f"{TEMP_DIR}/venv" + + expected_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + # Mock subprocess.run so we don't actually invoke uv; capture the calls instead + with mock.patch("subprocess.run", return_value=mock.Mock(returncode=0)) as mock_run: + args = [ + "content", + "venv", + "-g", + "7d59c5c7-c4a7-4950-acc3-3943b7192bc4", + env_path, + ] + apply_common_args(args, server=self.connect_server, key=self.api_key) + result = CliRunner().invoke(cli, args) + self.assertEqual(result.exit_code, 0, result.output) + + # Assert we made exactly two subprocess calls: uv venv, then uv pip install + self.assertEqual(mock_run.call_count, 2, mock_run.call_args_list) + + venv_args, venv_kwargs = mock_run.call_args_list[0] + venv_cmd = " ".join(venv_args[0]) + self.assertIn("uv venv", f" {venv_cmd} ") + self.assertIn(f"--python {expected_python_version}", venv_cmd) + self.assertIn(env_path, venv_cmd) + self.assertEqual(venv_kwargs.get("env", {}).get("UV_PYTHON_DOWNLOADS"), "auto") + + pip_args, _ = mock_run.call_args_list[1] + pip_cmd = " ".join(pip_args[0]) + self.assertIn("uv pip install", f" {pip_cmd} ") + self.assertIn(f"--python {env_path}", pip_cmd) + self.assertIn(" -r ", f" {pip_cmd} ") + @httpretty.activate(verbose=True, allow_net_connect=False) def test_build(self): register_uris(self.connect_server) From 544a8b318709934845d2fb7a14d6ce01f359988e Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 16 Dec 2025 17:56:40 +0100 Subject: [PATCH 4/9] hardcode python version --- rsconnect/main.py | 10 +++++----- tests/test_main_content.py | 7 ++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index 970a5afc..e89b7165 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2869,11 +2869,13 @@ def content_venv( output_params(ctx, locals().items()) uv_path = shutil.which("uv") if not uv_path: - raise RSConnectException("uv is required for `rsconnect content venv`. make sure it's available in your PATH and try again.") + raise RSConnectException( + "uv is required for `rsconnect content venv`. make sure it's available in your PATH and try again." + ) def _python_version_from_header(header: Optional[str]) -> str: header = header or "" - *_ , version = header.split("python=", 1) + *_, version = header.split("python=", 1) version = version.split(".")[:2] # major.minor return ".".join(version) @@ -2924,9 +2926,7 @@ def _guid_for_current_server(server_url: str) -> Optional[str]: raise RSConnectException("uv venv failed with exit code %d" % venv_result.returncode) logger.info("Syncing environment %s" % env_path) - result = subprocess.run([ - uv_path, "pip", "install", "--python", env_path, "-r", lockfile.name - ]) + result = subprocess.run([uv_path, "pip", "install", "--python", env_path, "-r", lockfile.name]) if result.returncode != 0: raise RSConnectException("uv pip install failed with exit code %d" % result.returncode) diff --git a/tests/test_main_content.py b/tests/test_main_content.py index 3a30d034..bd929a1d 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -4,7 +4,6 @@ import tarfile import unittest from unittest import mock -import sys import httpretty from click.testing import CliRunner @@ -54,7 +53,7 @@ def register_content_endpoints(i: int, guid: str): body="click==8.1.3\n", adding_headers={ "Content-Type": "text/plain", - "Generated-By": f"connect; python={sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "Generated-By": "connect; python=11.99.23", }, ) @@ -222,8 +221,6 @@ def test_content_venv(self): register_uris(self.connect_server) env_path = f"{TEMP_DIR}/venv" - expected_python_version = f"{sys.version_info.major}.{sys.version_info.minor}" - # Mock subprocess.run so we don't actually invoke uv; capture the calls instead with mock.patch("subprocess.run", return_value=mock.Mock(returncode=0)) as mock_run: args = [ @@ -243,7 +240,7 @@ def test_content_venv(self): venv_args, venv_kwargs = mock_run.call_args_list[0] venv_cmd = " ".join(venv_args[0]) self.assertIn("uv venv", f" {venv_cmd} ") - self.assertIn(f"--python {expected_python_version}", venv_cmd) + self.assertIn("--python 11.99", venv_cmd) self.assertIn(env_path, venv_cmd) self.assertEqual(venv_kwargs.get("env", {}).get("UV_PYTHON_DOWNLOADS"), "auto") From 8daf3ff8baf4986807d43094fb3dc941298afeb5 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 16 Dec 2025 18:00:03 +0100 Subject: [PATCH 5/9] CHANGELOG --- docs/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9b910749..ac4920d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] - ?? + +### Added + +- `rsconnect content get-lockfile` command allows fetching a lockfile with the + 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. + + ## [1.28.2] - 2025-12-05 ### Fixed From c198250a50feaeb75d9430928841d023a98dcac0 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 16 Dec 2025 18:04:01 +0100 Subject: [PATCH 6/9] Make the test more solid toward windows --- tests/test_main_content.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_main_content.py b/tests/test_main_content.py index bd929a1d..038173fb 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -4,6 +4,7 @@ import tarfile import unittest from unittest import mock +import re import httpretty from click.testing import CliRunner @@ -239,16 +240,16 @@ def test_content_venv(self): venv_args, venv_kwargs = mock_run.call_args_list[0] venv_cmd = " ".join(venv_args[0]) - self.assertIn("uv venv", f" {venv_cmd} ") + self.assertRegex(venv_cmd.lower(), r"uv(?:\.exe)?\s+venv\b") self.assertIn("--python 11.99", venv_cmd) self.assertIn(env_path, venv_cmd) self.assertEqual(venv_kwargs.get("env", {}).get("UV_PYTHON_DOWNLOADS"), "auto") pip_args, _ = mock_run.call_args_list[1] pip_cmd = " ".join(pip_args[0]) - self.assertIn("uv pip install", f" {pip_cmd} ") + self.assertRegex(pip_cmd.lower(), r"uv(?:\.exe)?\s+pip\s+install\b") self.assertIn(f"--python {env_path}", pip_cmd) - self.assertIn(" -r ", f" {pip_cmd} ") + self.assertIn(" -r ", pip_cmd) @httpretty.activate(verbose=True, allow_net_connect=False) def test_build(self): From fe798c13833e1d35304344aed041062425e5803b Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Tue, 16 Dec 2025 18:05:07 +0100 Subject: [PATCH 7/9] remove stray re --- tests/test_main_content.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_main_content.py b/tests/test_main_content.py index 038173fb..9fdcd22c 100644 --- a/tests/test_main_content.py +++ b/tests/test_main_content.py @@ -4,7 +4,6 @@ import tarfile import unittest from unittest import mock -import re import httpretty from click.testing import CliRunner From eac27be1701e528f8f1ba05eee5c3716794611f6 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 17 Dec 2025 14:36:05 +0100 Subject: [PATCH 8/9] Expand command help --- rsconnect/main.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rsconnect/main.py b/rsconnect/main.py index e89b7165..cecb3c62 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2837,6 +2837,13 @@ def content_get_lockfile( @content.command( name="venv", short_help="Replicate a Python environment from Connect", + help="Create a ENV_PATH Python virtual environment that mimics " + "the environment of a deployed content item on Posit Connect. " + "This will use the 'uv' tool to locally create and manage the virtual environment. " + "If the required Python version isn't already installed, uv will download it automatically." + "\n\n" + "run it from the directory of a deployed content item to auto-detect the GUID, " + "or provide the --guid option to specify a content item explicitly." ) @server_args @spcs_args From 14fd80ca923247c74f3159311001bdfc0395a850 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Wed, 17 Dec 2025 14:39:20 +0100 Subject: [PATCH 9/9] format --- rsconnect/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index cecb3c62..5ba24c59 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2838,12 +2838,12 @@ def content_get_lockfile( name="venv", short_help="Replicate a Python environment from Connect", help="Create a ENV_PATH Python virtual environment that mimics " - "the environment of a deployed content item on Posit Connect. " - "This will use the 'uv' tool to locally create and manage the virtual environment. " - "If the required Python version isn't already installed, uv will download it automatically." - "\n\n" - "run it from the directory of a deployed content item to auto-detect the GUID, " - "or provide the --guid option to specify a content item explicitly." + "the environment of a deployed content item on Posit Connect. " + "This will use the 'uv' tool to locally create and manage the virtual environment. " + "If the required Python version isn't already installed, uv will download it automatically." + "\n\n" + "run it from the directory of a deployed content item to auto-detect the GUID, " + "or provide the --guid option to specify a content item explicitly.", ) @server_args @spcs_args