diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index caecc6ed..a66cc122 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,15 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `rsconnect content get-lockfile` command allows fetching a lockfile with the -dependencies installed by connect to run the deployed content + 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. + 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. + supply an explicit requirements file instead of detecting the environment. +- `uv.lock` can now be supplied via `--requirements-file` for deploy and write-manifest. - Bundle uploads now include git metadata (source, source_repo, source_branch, source_commit) when deploying from a git repository. This metadata is automatically detected and sent to Posit Connect 2025.12.0 or later. Use `--metadata key=value` to provide additional metadata or override detected values. Use `--no-metadata` to disable automatic detection. (#736) + supply an explicit requirements file instead of detecting the environment. + ## [1.28.2] - 2025-12-05 diff --git a/rsconnect/main.py b/rsconnect/main.py index 7b96021c..e939ab30 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1095,8 +1095,10 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): type=click.Path(dir_okay=False), default="requirements.txt", help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the notebook directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -1274,8 +1276,10 @@ def deploy_notebook( type=click.Path(dir_okay=False), default="requirements.txt", help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the notebook directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -1529,8 +1533,10 @@ def deploy_manifest( type=click.Path(dir_okay=False), default="requirements.txt", help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the project directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -1957,8 +1963,10 @@ def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc "-r", type=click.Path(dir_okay=False), help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the deployment directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -2175,8 +2183,10 @@ def write_manifest(): "-r", type=click.Path(dir_okay=False), help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the notebook directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -2292,8 +2302,10 @@ def write_manifest_notebook( "-r", type=click.Path(exists=True, dir_okay=False), help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the notebook directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( @@ -2445,7 +2457,9 @@ def write_manifest_voila( "-r", type=click.Path(dir_okay=False), help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " "Must be inside the project directory." ), ) @@ -2660,8 +2674,10 @@ def generate_write_manifest_python(app_mode: AppMode, alias: str, desc: Optional "-r", type=click.Path(dir_okay=False), help=( - "Path to requirements file to record in the manifest instead of detecting the environment. " - "Must be inside the application directory. Use 'none' to capture via pip freeze." + "Path to requirements file listing the project dependencies. " + "Any file compatible with requirements.txt format or uv.lock is accepted, " + "a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. " + "Must be inside the project directory." ), ) @click.option( diff --git a/rsconnect/subprocesses/inspect_environment.py b/rsconnect/subprocesses/inspect_environment.py index 92af5452..8fcd5fd3 100644 --- a/rsconnect/subprocesses/inspect_environment.py +++ b/rsconnect/subprocesses/inspect_environment.py @@ -13,6 +13,7 @@ import json import locale import os +import tempfile import re import subprocess import sys @@ -74,6 +75,8 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require if requirements_file is None: result = pip_freeze() + 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() @@ -186,6 +189,66 @@ def pip_freeze(): } +def uv_export(dirname: str, lock_filename: str): + """ + Export requirements from a uv.lock file using `uv export`. + """ + lock_path = lock_filename + if not os.path.isabs(lock_filename): + lock_path = os.path.join(dirname, lock_filename) + + if not os.path.exists(lock_path): + raise EnvironmentException("uv.lock not found: %s" % lock_filename) + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = os.path.join(tmpdir, "requirements.txt.lock") + try: + result = subprocess.run( + [ + "uv", + "export", + "--format", + "requirements-txt", + "--frozen", + "--no-hashes", + "--no-annotate", + "--offline", + "--no-header", + "--no-emit-project", + "--output-file", + output_path, + ], + cwd=os.path.dirname(lock_path), + stdout=sys.stderr, + stderr=sys.stderr, + check=False, + ) + except Exception as exception: + raise EnvironmentException("Error during uv export: %s" % str(exception)) + + if result.returncode != 0: + raise EnvironmentException("Error during uv export: exited with code %d" % result.returncode) + + with open(output_path, mode="r", encoding="utf-8") as output_file: + exported = output_file.read() + + requirements = filter_pip_freeze_output(exported) + requirements = ( + "# requirements.txt.lock generated from uv.lock by rsconnect-python on " + + str(datetime.datetime.now(datetime.timezone.utc)) + + "\n" + + requirements + ) + + return { + "filename": "requirements.txt.lock", + "contents": requirements, + "source": "uv_lock", + "package_manager": "uv", + "pip": None, + } + + def filter_pip_freeze_output(pip_stdout: str): # Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]` return "\n".join( diff --git a/tests/test_environment.py b/tests/test_environment.py index 0495fc90..187e2a43 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -10,7 +10,12 @@ import rsconnect.environment from rsconnect.exception import RSConnectException from rsconnect.environment import Environment, which_python -from rsconnect.subprocesses.inspect_environment import get_python_version, get_default_locale, filter_pip_freeze_output +from rsconnect.subprocesses.inspect_environment import ( + detect_environment, + filter_pip_freeze_output, + get_default_locale, + get_python_version, +) from .utils import get_dir @@ -125,6 +130,69 @@ def test_filter_pip_freeze_output(self): self.assertEqual(filtered, expected) +def test_uv_lock_export(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "pyproject.toml").write_text( + "[project]\nname='demo'\nversion='0.0.0'\n" + "dependencies=['aiofiles==24.1.0','annotated-doc==0.0.4','annotated-types==0.7.0']\n", + encoding="utf-8", + ) + (project_dir / "uv.lock").write_text( + """version = 1 +revision = 3 +requires-python = ">=3.11.3" + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://example.com/aiofiles-24.1.0.tar.gz", hash = "sha256:1" } +wheels = [{ url = "https://example.com/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:2" }] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://example.com/annotated_doc-0.0.4.tar.gz", hash = "sha256:3" } +wheels = [{ url = "https://example.com/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:4" }] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://example.com/annotated_types-0.7.0.tar.gz", hash = "sha256:5" } +wheels = [{ url = "https://example.com/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:6" }] + +[[package]] +name = "demo" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "annotated-doc" }, + { name = "annotated-types" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = "==24.1.0" }, + { name = "annotated-doc", specifier = "==0.0.4" }, + { name = "annotated-types", specifier = "==0.7.0" }, +]""", + encoding="utf-8", + ) + + env = detect_environment(str(project_dir), requirements_file="uv.lock") + + assert env.filename == "requirements.txt.lock" + assert "aiofiles==24.1.0" in env.contents + assert "annotated-doc==0.0.4" in env.contents + assert "annotated-types==0.7.0" in env.contents + assert env.source == "uv_lock" + assert env.package_manager == "uv" + + class WhichPythonTestCase(TestCase): def test_default(self): self.assertEqual(which_python(), sys.executable)