Skip to content

Commit 4c32231

Browse files
authored
feat: basic support for uv.lock (#750)
1 parent 539ec88 commit 4c32231

File tree

4 files changed

+169
-19
lines changed

4 files changed

+169
-19
lines changed

docs/CHANGELOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `rsconnect content get-lockfile` command allows fetching a lockfile with the
13-
dependencies installed by connect to run the deployed content
13+
dependencies installed by connect to run the deployed content
1414
- `rsconnect content venv` command recreates a local python environment
15-
equal to the one used by connect to run the content.
15+
equal to the one used by connect to run the content.
1616
- Added `--requirements-file` option on deploy and write-manifest commands to
17-
supply an explicit requirements file instead of detecting the environment.
17+
supply an explicit requirements file instead of detecting the environment.
18+
- `uv.lock` can now be supplied via `--requirements-file` for deploy and write-manifest.
1819
- Bundle uploads now include git metadata (source, source_repo, source_branch, source_commit)
1920
when deploying from a git repository. This metadata is automatically detected and sent to
2021
Posit Connect 2025.12.0 or later. Use `--metadata key=value` to provide additional metadata
2122
or override detected values. Use `--no-metadata` to disable automatic detection. (#736)
23+
supply an explicit requirements file instead of detecting the environment.
24+
2225

2326
## [1.28.2] - 2025-12-05
2427

rsconnect/main.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,8 +1095,10 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str):
10951095
type=click.Path(dir_okay=False),
10961096
default="requirements.txt",
10971097
help=(
1098-
"Path to requirements file to record in the manifest instead of detecting the environment. "
1099-
"Must be inside the notebook directory. Use 'none' to capture via pip freeze."
1098+
"Path to requirements file listing the project dependencies. "
1099+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
1100+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
1101+
"Must be inside the project directory."
11001102
),
11011103
)
11021104
@click.option(
@@ -1274,8 +1276,10 @@ def deploy_notebook(
12741276
type=click.Path(dir_okay=False),
12751277
default="requirements.txt",
12761278
help=(
1277-
"Path to requirements file to record in the manifest instead of detecting the environment. "
1278-
"Must be inside the notebook directory. Use 'none' to capture via pip freeze."
1279+
"Path to requirements file listing the project dependencies. "
1280+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
1281+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
1282+
"Must be inside the project directory."
12791283
),
12801284
)
12811285
@click.option(
@@ -1529,8 +1533,10 @@ def deploy_manifest(
15291533
type=click.Path(dir_okay=False),
15301534
default="requirements.txt",
15311535
help=(
1532-
"Path to requirements file to record in the manifest instead of detecting the environment. "
1533-
"Must be inside the project directory. Use 'none' to capture via pip freeze."
1536+
"Path to requirements file listing the project dependencies. "
1537+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
1538+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
1539+
"Must be inside the project directory."
15341540
),
15351541
)
15361542
@click.option(
@@ -1957,8 +1963,10 @@ def generate_deploy_python(app_mode: AppMode, alias: str, min_version: str, desc
19571963
"-r",
19581964
type=click.Path(dir_okay=False),
19591965
help=(
1960-
"Path to requirements file to record in the manifest instead of detecting the environment. "
1961-
"Must be inside the deployment directory. Use 'none' to capture via pip freeze."
1966+
"Path to requirements file listing the project dependencies. "
1967+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
1968+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
1969+
"Must be inside the project directory."
19621970
),
19631971
)
19641972
@click.option(
@@ -2175,8 +2183,10 @@ def write_manifest():
21752183
"-r",
21762184
type=click.Path(dir_okay=False),
21772185
help=(
2178-
"Path to requirements file to record in the manifest instead of detecting the environment. "
2179-
"Must be inside the notebook directory. Use 'none' to capture via pip freeze."
2186+
"Path to requirements file listing the project dependencies. "
2187+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
2188+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
2189+
"Must be inside the project directory."
21802190
),
21812191
)
21822192
@click.option(
@@ -2292,8 +2302,10 @@ def write_manifest_notebook(
22922302
"-r",
22932303
type=click.Path(exists=True, dir_okay=False),
22942304
help=(
2295-
"Path to requirements file to record in the manifest instead of detecting the environment. "
2296-
"Must be inside the notebook directory. Use 'none' to capture via pip freeze."
2305+
"Path to requirements file listing the project dependencies. "
2306+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
2307+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
2308+
"Must be inside the project directory."
22972309
),
22982310
)
22992311
@click.option(
@@ -2445,7 +2457,9 @@ def write_manifest_voila(
24452457
"-r",
24462458
type=click.Path(dir_okay=False),
24472459
help=(
2448-
"Path to requirements file to record in the manifest instead of detecting the environment. "
2460+
"Path to requirements file listing the project dependencies. "
2461+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
2462+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
24492463
"Must be inside the project directory."
24502464
),
24512465
)
@@ -2660,8 +2674,10 @@ def generate_write_manifest_python(app_mode: AppMode, alias: str, desc: Optional
26602674
"-r",
26612675
type=click.Path(dir_okay=False),
26622676
help=(
2663-
"Path to requirements file to record in the manifest instead of detecting the environment. "
2664-
"Must be inside the application directory. Use 'none' to capture via pip freeze."
2677+
"Path to requirements file listing the project dependencies. "
2678+
"Any file compatible with requirements.txt format or uv.lock is accepted, "
2679+
"a requirements.txt.lock retrieved with 'rsconnect content get-lockfile' is also supported. "
2680+
"Must be inside the project directory."
26652681
),
26662682
)
26672683
@click.option(

rsconnect/subprocesses/inspect_environment.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import json
1414
import locale
1515
import os
16+
import tempfile
1617
import re
1718
import subprocess
1819
import sys
@@ -74,6 +75,8 @@ def detect_environment(dirname: str, requirements_file: Optional[str] = "require
7475

7576
if requirements_file is None:
7677
result = pip_freeze()
78+
elif os.path.basename(requirements_file) == "uv.lock":
79+
result = uv_export(dirname, requirements_file)
7780
else:
7881
result = output_file(dirname, requirements_file, "pip") or pip_freeze()
7982

@@ -186,6 +189,66 @@ def pip_freeze():
186189
}
187190

188191

192+
def uv_export(dirname: str, lock_filename: str):
193+
"""
194+
Export requirements from a uv.lock file using `uv export`.
195+
"""
196+
lock_path = lock_filename
197+
if not os.path.isabs(lock_filename):
198+
lock_path = os.path.join(dirname, lock_filename)
199+
200+
if not os.path.exists(lock_path):
201+
raise EnvironmentException("uv.lock not found: %s" % lock_filename)
202+
203+
with tempfile.TemporaryDirectory() as tmpdir:
204+
output_path = os.path.join(tmpdir, "requirements.txt.lock")
205+
try:
206+
result = subprocess.run(
207+
[
208+
"uv",
209+
"export",
210+
"--format",
211+
"requirements-txt",
212+
"--frozen",
213+
"--no-hashes",
214+
"--no-annotate",
215+
"--offline",
216+
"--no-header",
217+
"--no-emit-project",
218+
"--output-file",
219+
output_path,
220+
],
221+
cwd=os.path.dirname(lock_path),
222+
stdout=sys.stderr,
223+
stderr=sys.stderr,
224+
check=False,
225+
)
226+
except Exception as exception:
227+
raise EnvironmentException("Error during uv export: %s" % str(exception))
228+
229+
if result.returncode != 0:
230+
raise EnvironmentException("Error during uv export: exited with code %d" % result.returncode)
231+
232+
with open(output_path, mode="r", encoding="utf-8") as output_file:
233+
exported = output_file.read()
234+
235+
requirements = filter_pip_freeze_output(exported)
236+
requirements = (
237+
"# requirements.txt.lock generated from uv.lock by rsconnect-python on "
238+
+ str(datetime.datetime.now(datetime.timezone.utc))
239+
+ "\n"
240+
+ requirements
241+
)
242+
243+
return {
244+
"filename": "requirements.txt.lock",
245+
"contents": requirements,
246+
"source": "uv_lock",
247+
"package_manager": "uv",
248+
"pip": None,
249+
}
250+
251+
189252
def filter_pip_freeze_output(pip_stdout: str):
190253
# Filter out dependency on `rsconnect` and ignore output lines from pip which start with `[notice]`
191254
return "\n".join(

tests/test_environment.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010
import rsconnect.environment
1111
from rsconnect.exception import RSConnectException
1212
from rsconnect.environment import Environment, which_python
13-
from rsconnect.subprocesses.inspect_environment import get_python_version, get_default_locale, filter_pip_freeze_output
13+
from rsconnect.subprocesses.inspect_environment import (
14+
detect_environment,
15+
filter_pip_freeze_output,
16+
get_default_locale,
17+
get_python_version,
18+
)
1419

1520
from .utils import get_dir
1621

@@ -125,6 +130,69 @@ def test_filter_pip_freeze_output(self):
125130
self.assertEqual(filtered, expected)
126131

127132

133+
def test_uv_lock_export(tmp_path):
134+
project_dir = tmp_path / "project"
135+
project_dir.mkdir()
136+
(project_dir / "pyproject.toml").write_text(
137+
"[project]\nname='demo'\nversion='0.0.0'\n"
138+
"dependencies=['aiofiles==24.1.0','annotated-doc==0.0.4','annotated-types==0.7.0']\n",
139+
encoding="utf-8",
140+
)
141+
(project_dir / "uv.lock").write_text(
142+
"""version = 1
143+
revision = 3
144+
requires-python = ">=3.11.3"
145+
146+
[[package]]
147+
name = "aiofiles"
148+
version = "24.1.0"
149+
source = { registry = "https://pypi.org/simple" }
150+
sdist = { url = "https://example.com/aiofiles-24.1.0.tar.gz", hash = "sha256:1" }
151+
wheels = [{ url = "https://example.com/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:2" }]
152+
153+
[[package]]
154+
name = "annotated-doc"
155+
version = "0.0.4"
156+
source = { registry = "https://pypi.org/simple" }
157+
sdist = { url = "https://example.com/annotated_doc-0.0.4.tar.gz", hash = "sha256:3" }
158+
wheels = [{ url = "https://example.com/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:4" }]
159+
160+
[[package]]
161+
name = "annotated-types"
162+
version = "0.7.0"
163+
source = { registry = "https://pypi.org/simple" }
164+
sdist = { url = "https://example.com/annotated_types-0.7.0.tar.gz", hash = "sha256:5" }
165+
wheels = [{ url = "https://example.com/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:6" }]
166+
167+
[[package]]
168+
name = "demo"
169+
version = "0.0.0"
170+
source = { virtual = "." }
171+
dependencies = [
172+
{ name = "aiofiles" },
173+
{ name = "annotated-doc" },
174+
{ name = "annotated-types" },
175+
]
176+
177+
[package.metadata]
178+
requires-dist = [
179+
{ name = "aiofiles", specifier = "==24.1.0" },
180+
{ name = "annotated-doc", specifier = "==0.0.4" },
181+
{ name = "annotated-types", specifier = "==0.7.0" },
182+
]""",
183+
encoding="utf-8",
184+
)
185+
186+
env = detect_environment(str(project_dir), requirements_file="uv.lock")
187+
188+
assert env.filename == "requirements.txt.lock"
189+
assert "aiofiles==24.1.0" in env.contents
190+
assert "annotated-doc==0.0.4" in env.contents
191+
assert "annotated-types==0.7.0" in env.contents
192+
assert env.source == "uv_lock"
193+
assert env.package_manager == "uv"
194+
195+
128196
class WhichPythonTestCase(TestCase):
129197
def test_default(self):
130198
self.assertEqual(which_python(), sys.executable)

0 commit comments

Comments
 (0)