Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 31 additions & 15 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."
),
)
Expand Down Expand Up @@ -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(
Expand Down
63 changes: 63 additions & 0 deletions rsconnect/subprocesses/inspect_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import locale
import os
import tempfile
import re
import subprocess
import sys
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 "
Copy link
Contributor

@mconflitti-pbc mconflitti-pbc Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am providing a uv.lock file and this message is not what I am seeing. Instead I see from requirements.txt.lock

Copy link
Collaborator Author

@amol- amol- Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhm, Can you elaborate?

This is a comment that is prefixed to the requirements file uploaded to connect so that when the bundle is accessed for debugging purposes there is a hint that the requirements were not written by a user but were generated from a uv.lock

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, actually referring to this in the logs when deploying with uv.lock attached:

2025/12/19 18:30:53.191895006 Generating lockfile python/requirements.txt.lock from requirements.txt.lock

Copy link
Collaborator Author

@amol- amol- Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes, that's connect server producing those logs. From the point of view of connect you still uploaded a requirements.txt.lock so it emits that log

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly confusing but only if you are looking for it! Thanks for clarifying

+ 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(
Expand Down
70 changes: 69 additions & 1 deletion tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading