From f07986fbcebb9b4fce916b12c5ad21370429d67a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 1 Dec 2025 08:50:04 -0500 Subject: [PATCH 1/7] Claude's first pass at adding bundle metadata support --- rsconnect/api.py | 72 ++++++++++- rsconnect/git_metadata.py | 180 ++++++++++++++++++++++++++ rsconnect/http_support.py | 52 +++++++- rsconnect/main.py | 141 ++++++++++++++++++++ tests/test_git_metadata.py | 256 +++++++++++++++++++++++++++++++++++++ 5 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 rsconnect/git_metadata.py create mode 100644 tests/test_git_metadata.py diff --git a/rsconnect/api.py b/rsconnect/api.py index 1c5817ef..e46e468e 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -9,6 +9,7 @@ import datetime import hashlib import hmac +import json import os import re import sys @@ -55,7 +56,14 @@ from .certificates import read_certificate_file from .environment import fake_module_file_from_directory from .exception import DeploymentFailedException, RSConnectException -from .http_support import CookieJar, HTTPResponse, HTTPServer, JsonData, append_to_path +from .http_support import ( + CookieJar, + HTTPResponse, + HTTPServer, + JsonData, + append_to_path, + create_multipart_form_data, +) from .log import cls_logged, connect_logger, console_logger, logger from .metadata import AppStore, ServerStore from .models import ( @@ -76,6 +84,7 @@ ) from .snowflake import generate_jwt, get_parameters from .timeouts import get_task_timeout, get_task_timeout_help_message +from .utils_package import compare_semvers if TYPE_CHECKING: import logging @@ -367,6 +376,26 @@ class RSConnectClientDeployResult(TypedDict): title: str | None +def server_supports_git_metadata(server_version: Optional[str]) -> bool: + """ + Check if the server version supports git metadata in bundle uploads. + + Git metadata support was added in Connect 2025.11.0. + + :param server_version: The Connect server version string + :return: True if the server supports git metadata, False otherwise + """ + if not server_version: + return False + + try: + return compare_semvers(server_version, "2025.11.0") >= 0 + except Exception: + # If we can't parse the version, assume it doesn't support it + logger.debug(f"Unable to parse server version: {server_version}") + return False + + class RSConnectClient(HTTPServer): def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None): if cookies is None: @@ -488,11 +517,34 @@ def content_create(self, name: str) -> ContentItemV1: response = self._server.handle_bad_response(response) return response - def content_upload_bundle(self, content_guid: str, tarball: typing.IO[bytes]) -> BundleMetadata: - response = cast( - Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball) - ) - response = self._server.handle_bad_response(response) + def content_upload_bundle( + self, content_guid: str, tarball: typing.IO[bytes], metadata: Optional[dict[str, str]] = None + ) -> BundleMetadata: + """ + Upload a bundle to the server. + + :param app_id: Application ID + :param tarball: Bundle tarball file object + :param metadata: Optional metadata dictionary (e.g., git metadata) + :return: ContentItemV0 with bundle information + """ + if metadata: + # Use multipart form upload when metadata is provided + tarball_content = tarball.read() + fields = { + "archive": ("bundle.tar.gz", tarball_content, "application/x-tar"), + "metadata": json.dumps(metadata), + } + body, content_type = create_multipart_form_data(fields) + response = cast( + Union[BundleMetadata, HTTPResponse], + self.post("v1/content/%s/bundles" % content_guid, body=body, headers={"Content-Type": content_type}), + ) + else: + response = cast( + Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball) + ) + response = self._server.handle_bad_response(response) return response def content_update(self, content_guid: str, updates: Mapping[str, str | None]) -> ContentItemV1: @@ -571,6 +623,7 @@ def deploy( tarball: IO[bytes], env_vars: Optional[dict[str, str]] = None, activate: bool = True, + metadata: Optional[dict[str, str]] = None, ) -> RSConnectClientDeployResult: if app_id is None: if app_name is None: @@ -598,7 +651,7 @@ def deploy( result = self._server.handle_bad_response(result) app["title"] = app_title - app_bundle = self.content_upload_bundle(app_guid, tarball) + app_bundle = self.content_upload_bundle(app_guid, tarball, metadata=metadata) task = self.content_deploy(app_guid, app_bundle["id"], activate=activate) @@ -724,6 +777,7 @@ def __init__( visibility: Optional[str] = None, disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, str]] = None, ) -> None: self.remote_server: TargetableServer self.client: RSConnectClient | PositClient @@ -737,6 +791,7 @@ def __init__( self.visibility = visibility self.disable_env_management = disable_env_management self.env_vars = env_vars + self.metadata = metadata self.app_mode: AppMode | None = None self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path)) self.app_store_version: int | None = None @@ -785,6 +840,7 @@ def fromConnectServer( visibility: Optional[str] = None, disable_env_management: Optional[bool] = None, env_vars: Optional[dict[str, str]] = None, + metadata: Optional[dict[str, str]] = None, ): return cls( ctx=ctx, @@ -807,6 +863,7 @@ def fromConnectServer( visibility=visibility, disable_env_management=disable_env_management, env_vars=env_vars, + metadata=metadata, ) def output_overlap_header(self, previous: bool) -> bool: @@ -1069,6 +1126,7 @@ def deploy_bundle(self, activate: bool = True): self.bundle, self.env_vars, activate=activate, + metadata=self.metadata, ) self.deployed_info = result return self diff --git a/rsconnect/git_metadata.py b/rsconnect/git_metadata.py new file mode 100644 index 00000000..7a9e548e --- /dev/null +++ b/rsconnect/git_metadata.py @@ -0,0 +1,180 @@ +""" +Git metadata detection utilities for bundle uploads +""" + +from __future__ import annotations + +import os +import subprocess +from os.path import abspath, dirname, exists, join +from typing import Optional +from urllib.parse import urlparse + +from .log import logger + + +def _run_git_command(args: list[str], cwd: str) -> Optional[str]: + """ + Run a git command and return its output. + + :param args: git command arguments + :param cwd: working directory + :return: command output or None if command failed + """ + try: + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + except (subprocess.SubprocessError, FileNotFoundError, OSError): + return None + + +def is_git_repo(directory: str) -> bool: + """ + Check if directory is inside a git repository. + + :param directory: directory to check + :return: True if inside a git repo, False otherwise + """ + result = _run_git_command(["rev-parse", "--git-dir"], directory) + return result is not None + + +def has_uncommitted_changes(directory: str) -> bool: + """ + Check if the git repository has uncommitted changes. + + :param directory: directory to check + :return: True if there are uncommitted changes + """ + # Check for staged and unstaged changes + result = _run_git_command(["status", "--porcelain"], directory) + return bool(result) + + +def get_git_commit(directory: str) -> Optional[str]: + """ + Get the current git commit SHA. + + :param directory: directory to check + :return: commit SHA or None + """ + return _run_git_command(["rev-parse", "HEAD"], directory) + + +def get_git_branch(directory: str) -> Optional[str]: + """ + Get the current git branch name or tag. + + :param directory: directory to check + :return: branch/tag name or None + """ + # First try to get branch name + branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], directory) + + # If we're in detached HEAD state, try to get tag + if branch == "HEAD": + tag = _run_git_command(["describe", "--exact-match", "--tags"], directory) + if tag: + return tag + + return branch + + +def get_git_remote_url(directory: str, remote: str = "origin") -> Optional[str]: + """ + Get the URL of a git remote. + + :param directory: directory to check + :param remote: remote name (default: "origin") + :return: remote URL or None + """ + return _run_git_command(["remote", "get-url", remote], directory) + + +def normalize_git_url_to_https(url: Optional[str]) -> Optional[str]: + """ + Normalize a git URL to HTTPS format. + + Converts SSH URLs like git@github.com:user/repo.git to + https://github.com/user/repo.git + + :param url: git URL to normalize + :return: normalized HTTPS URL or original if already HTTPS/not recognized + """ + if not url: + return url + + # Already HTTPS + if url.startswith("https://"): + return url + + # Handle git@ SSH format + if url.startswith("git@"): + # git@github.com:user/repo.git -> https://github.com/user/repo.git + # Remove git@ prefix + url = url[4:] + # Replace first : with / + url = url.replace(":", "/", 1) + # Add https:// + return f"https://{url}" + + # Handle ssh:// format + if url.startswith("ssh://"): + # ssh://git@github.com/user/repo.git -> https://github.com/user/repo.git + parsed = urlparse(url) + if parsed.hostname: + path = parsed.path + return f"https://{parsed.hostname}{path}" + + # Return as-is if we can't normalize + return url + + +def detect_git_metadata(directory: str, remote: str = "origin") -> dict[str, str]: + """ + Detect git metadata for the given directory. + + :param directory: directory to inspect + :param remote: git remote name to use (default: "origin") + :return: dictionary with source, source_repo, source_branch, source_commit keys + """ + metadata: dict[str, str] = {} + + if not is_git_repo(directory): + logger.debug(f"Directory {directory} is not a git repository") + return metadata + + # Get commit SHA + commit = get_git_commit(directory) + if commit: + # Check for uncommitted changes + if has_uncommitted_changes(directory): + commit = f"{commit}-dirty" + metadata["source_commit"] = commit + + # Get branch/tag + branch = get_git_branch(directory) + if branch: + metadata["source_branch"] = branch + + # Get remote URL and normalize to HTTPS + remote_url = get_git_remote_url(directory, remote) + if remote_url: + normalized_url = normalize_git_url_to_https(remote_url) + if normalized_url: + metadata["source_repo"] = normalized_url + + # Always set source to "git" if we got any metadata + if metadata: + metadata["source"] = "git" + logger.debug(f"Detected git metadata: {metadata}") + + return metadata diff --git a/rsconnect/http_support.py b/rsconnect/http_support.py index e0e97b38..4e016fe9 100644 --- a/rsconnect/http_support.py +++ b/rsconnect/http_support.py @@ -161,6 +161,53 @@ def append_to_path(uri: str, path: str): return uri +def create_multipart_form_data( + fields: Dict[str, Union[str, Tuple[str, bytes, str]]], + boundary: Optional[str] = None, +) -> Tuple[bytes, str]: + """ + Create multipart/form-data body and content-type header. + + :param fields: Dictionary of field names to values. Values can be: + - str: Plain text field value + - Tuple[str, bytes, str]: (filename, file_content, content_type) for file uploads + :param boundary: Optional boundary string. If not provided, one will be generated. + :return: Tuple of (body bytes, content-type header value) + """ + import secrets + + if boundary is None: + boundary = secrets.token_hex(16) + + body_parts = [] + + for field_name, field_value in fields.items(): + body_parts.append(f"--{boundary}".encode("utf-8")) + + if isinstance(field_value, tuple): + # File field + filename, file_content, content_type = field_value + disposition = f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"' + body_parts.append(disposition.encode("utf-8")) + body_parts.append(f"Content-Type: {content_type}".encode("utf-8")) + body_parts.append(b"") + body_parts.append(file_content) + else: + # Plain text field + disposition = f'Content-Disposition: form-data; name="{field_name}"' + body_parts.append(disposition.encode("utf-8")) + body_parts.append(b"") + body_parts.append(field_value.encode("utf-8")) + + body_parts.append(f"--{boundary}--".encode("utf-8")) + body_parts.append(b"") + + body = b"\r\n".join(body_parts) + content_type = f"multipart/form-data; boundary={boundary}" + + return body, content_type + + class HTTPResponse(object): """ This class represents the result of executing an HTTP request. @@ -298,8 +345,11 @@ def post( path: str, query_params: Optional[Mapping[str, JsonData]] = None, body: str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None = None, + headers: Optional[Mapping[str, str]] = None, ) -> JsonData | HTTPResponse: - return self.request("POST", path, query_params, body) + if headers is None: + headers = {} + return self.request("POST", path, query_params, body, headers=headers) def patch( self, diff --git a/rsconnect/main.py b/rsconnect/main.py index 18afe1cc..7d6f5795 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -109,6 +109,8 @@ ) from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements +from .git_metadata import detect_git_metadata +from .api import server_supports_git_metadata T = TypeVar("T") P = ParamSpec("P") @@ -266,6 +268,60 @@ def validate_env_vars(ctx: click.Context, param: click.Parameter, all_values: tu return vars +def prepare_deploy_metadata( + directory: str, + metadata_overrides: tuple[str, ...], + no_metadata: bool, + server_version: Optional[str] = None, +) -> Optional[dict[str, str]]: + """ + Prepare metadata for bundle upload. + + :param directory: Directory to detect git metadata from + :param metadata_overrides: CLI metadata overrides (key=value pairs) + :param no_metadata: Flag to disable all metadata + :param server_version: Optional server version to check support + :return: Metadata dict or None if metadata should not be sent + """ + if no_metadata: + return None + + # Parse CLI metadata overrides + cli_metadata: dict[str, str] = {} + force_metadata = False + if metadata_overrides: + force_metadata = True + for item in metadata_overrides: + if "=" in item: + key, value = item.split("=", 1) + if value: # If value is not empty + cli_metadata[key] = value + else: # Empty value clears the key + cli_metadata[key] = "" + + # Auto-detect git metadata + detected_metadata = detect_git_metadata(directory) + + # Merge: CLI overrides take precedence, then remove empty values + final_metadata = {**detected_metadata, **cli_metadata} + final_metadata = {k: v for k, v in final_metadata.items() if v} + + # If no metadata collected, return None + if not final_metadata: + return None + + # Check if we should send metadata based on server version + if force_metadata: + # If CLI metadata was provided, always send it + return final_metadata + + # Otherwise, only send if server supports it + if server_supports_git_metadata(server_version): + return final_metadata + + return None + + def content_args(func: Callable[P, T]) -> Callable[P, T]: @click.option( "--new", @@ -305,6 +361,21 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]: "Previous bundle will continue to be served until the draft is published." ), ) + @click.option( + "--metadata", + multiple=True, + help=( + "Include metadata key-value pair with the bundle upload. " + "Use format: key=value. May be specified multiple times. " + "Use key= (empty value) to clear a detected value. " + "Forces metadata upload even on older servers that don't officially support it. [v2025.11.0+]" + ), + ) + @click.option( + "--no-metadata", + is_flag=True, + help="Disable automatic git metadata detection and upload.", + ) @functools.wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs): return func(*args, **kwargs) @@ -1052,6 +1123,8 @@ def deploy_notebook( env_management_r: Optional[bool], draft: bool, no_verify: bool = False, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1089,6 +1162,13 @@ def deploy_notebook( env_vars=env_vars, ) + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ce.validate_server().validate_app_mode(app_mode=app_mode) if app_mode == AppModes.STATIC: ce.make_bundle( @@ -1203,6 +1283,8 @@ def deploy_voila( no_verify: bool, draft: bool = False, connect_server: Optional[api.RSConnectServer] = None, # TODO: This appears to be unused + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1227,6 +1309,14 @@ def deploy_voila( env_vars=env_vars, ) + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + base_dir = path if isdir(path) else dirname(path) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ce.validate_server().validate_app_mode(app_mode=app_mode) ce.make_bundle( make_voila_bundle, @@ -1284,6 +1374,8 @@ def deploy_manifest( visibility: Optional[str], no_verify: bool, draft: bool, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1310,6 +1402,15 @@ def deploy_manifest( visibility=visibility, env_vars=env_vars, ) + + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + base_dir = dirname(file_name) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ( ce.validate_server() .validate_app_mode(app_mode=app_mode) @@ -1412,6 +1513,8 @@ def deploy_quarto( env_management_r: bool, no_verify: bool, draft: bool, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1452,6 +1555,14 @@ def deploy_quarto( disable_env_management=disable_env_management, env_vars=env_vars, ) + + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ( ce.validate_server() .validate_app_mode(app_mode=AppModes.STATIC_QUARTO) @@ -1532,6 +1643,8 @@ def deploy_tensorflow( image: Optional[str], no_verify: bool, draft: bool, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1553,6 +1666,14 @@ def deploy_tensorflow( title=title, env_vars=env_vars, ) + + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ( ce.validate_server() .validate_app_mode(app_mode=AppModes.TENSORFLOW) @@ -1628,6 +1749,8 @@ def deploy_html( no_verify: bool, draft: bool, connect_server: Optional[api.RSConnectServer] = None, + metadata: tuple[str, ...] = tuple(), + no_metadata: bool = False, ): set_verbosity(verbose) output_params(ctx, locals().items()) @@ -1667,6 +1790,14 @@ def deploy_html( env_vars=env_vars, ) + # Prepare metadata for upload + server_version = None + if isinstance(ce.client, RSConnectClient): + server_version = ce.client.server_settings().get("version", "") + base_dir = path if isdir(path) else dirname(path) + deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ( ce.validate_server() .validate_app_mode(app_mode=AppModes.STATIC) @@ -1783,6 +1914,8 @@ def deploy_app( secret: Optional[str], no_verify: bool, draft: bool, + metadata: tuple[str, ...], + no_metadata: bool, ): set_verbosity(verbose) entrypoint = validate_entry_point(entrypoint, directory) @@ -1795,6 +1928,9 @@ def deploy_app( if is_express_app(entrypoint + ".py", directory): entrypoint = "shiny.express.app:" + escape_to_var_name(entrypoint + ".py") + # Get server version for metadata support check + server_version = None + ce = RSConnectExecutor( ctx=ctx, name=name, @@ -1821,6 +1957,7 @@ def deploy_app( # 2024.01.1 or later, this can be removed. Requires access to the # Connect server version, which may be hidden. connect_version_string = ce.client.server_settings().get("version", "") + server_version = connect_version_string if connect_version_string: environment = fix_starlette_requirements( environment=environment, @@ -1833,6 +1970,10 @@ def deploy_app( fg="yellow", ) + # Prepare metadata for upload + deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) + ce.metadata = deploy_metadata + ce.validate_server() ce.validate_app_mode(app_mode=app_mode) ce.make_bundle( diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py new file mode 100644 index 00000000..fa90a7ac --- /dev/null +++ b/tests/test_git_metadata.py @@ -0,0 +1,256 @@ +""" +Tests for git metadata detection and integration +""" + +import os +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from rsconnect.git_metadata import ( + detect_git_metadata, + get_git_branch, + get_git_commit, + get_git_remote_url, + has_uncommitted_changes, + is_git_repo, + normalize_git_url_to_https, +) + + +class TestGitUrlNormalization: + def test_already_https(self): + url = "https://github.com/user/repo.git" + assert normalize_git_url_to_https(url) == url + + def test_git_ssh_format(self): + url = "git@github.com:user/repo.git" + expected = "https://github.com/user/repo.git" + assert normalize_git_url_to_https(url) == expected + + def test_ssh_url_format(self): + url = "ssh://git@github.com/user/repo.git" + expected = "https://github.com/user/repo.git" + assert normalize_git_url_to_https(url) == expected + + def test_none_input(self): + assert normalize_git_url_to_https(None) is None + + def test_unrecognized_format(self): + url = "file:///path/to/repo" + assert normalize_git_url_to_https(url) == url + + +class TestGitDetection: + @pytest.fixture + def git_repo(self): + """Create a temporary git repository for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Initialize git repo + subprocess.run(["git", "init"], cwd=tmpdir, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmpdir, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmpdir, check=True) + + # Create a file and commit + test_file = Path(tmpdir) / "test.txt" + test_file.write_text("test content") + subprocess.run(["git", "add", "."], cwd=tmpdir, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], cwd=tmpdir, check=True, capture_output=True + ) + + # Add a remote + subprocess.run( + ["git", "remote", "add", "origin", "git@github.com:user/repo.git"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + + yield tmpdir + + def test_is_git_repo_true(self, git_repo): + assert is_git_repo(git_repo) is True + + def test_is_git_repo_false(self): + with tempfile.TemporaryDirectory() as tmpdir: + assert is_git_repo(tmpdir) is False + + def test_get_git_commit(self, git_repo): + commit = get_git_commit(git_repo) + assert commit is not None + assert len(commit) == 40 # SHA-1 hash length + + def test_get_git_branch(self, git_repo): + branch = get_git_branch(git_repo) + # Default branch can be either 'master' or 'main' depending on git version + assert branch in ("master", "main") + + def test_get_git_remote_url(self, git_repo): + url = get_git_remote_url(git_repo, "origin") + assert url == "git@github.com:user/repo.git" + + def test_has_uncommitted_changes_false(self, git_repo): + assert has_uncommitted_changes(git_repo) is False + + def test_has_uncommitted_changes_true(self, git_repo): + # Create an uncommitted file + test_file = Path(git_repo) / "new_file.txt" + test_file.write_text("new content") + assert has_uncommitted_changes(git_repo) is True + + def test_detect_git_metadata_clean_repo(self, git_repo): + metadata = detect_git_metadata(git_repo) + + assert metadata["source"] == "git" + assert "source_commit" in metadata + assert len(metadata["source_commit"]) == 40 + assert not metadata["source_commit"].endswith("-dirty") + assert metadata["source_branch"] in ("master", "main") + assert metadata["source_repo"] == "https://github.com/user/repo.git" + + def test_detect_git_metadata_dirty_repo(self, git_repo): + # Create an uncommitted file + test_file = Path(git_repo) / "uncommitted.txt" + test_file.write_text("uncommitted content") + + metadata = detect_git_metadata(git_repo) + + assert metadata["source"] == "git" + assert metadata["source_commit"].endswith("-dirty") + + def test_detect_git_metadata_non_git_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + metadata = detect_git_metadata(tmpdir) + assert metadata == {} + + +class TestServerVersionSupport: + def test_server_supports_git_metadata(self): + from rsconnect.api import server_supports_git_metadata + + # Older version - no support + assert server_supports_git_metadata("2024.01.0") is False + assert server_supports_git_metadata("2025.10.0") is False + + # Exact version - supported + assert server_supports_git_metadata("2025.11.0") is True + + # Newer version - supported + assert server_supports_git_metadata("2025.12.0") is True + assert server_supports_git_metadata("2026.01.0") is True + + # None/empty - not supported + assert server_supports_git_metadata(None) is False + assert server_supports_git_metadata("") is False + + def test_server_supports_git_metadata_invalid_version(self): + from rsconnect.api import server_supports_git_metadata + + # Invalid version strings should return False + assert server_supports_git_metadata("invalid") is False + assert server_supports_git_metadata("not-a-version") is False + + +class TestMultipartFormData: + def test_create_multipart_form_data(self): + from rsconnect.http_support import create_multipart_form_data + + fields = { + "text_field": "plain text value", + "file_field": ("bundle.tar.gz", b"binary content", "application/x-tar"), + } + + body, content_type = create_multipart_form_data(fields) + + assert isinstance(body, bytes) + assert content_type.startswith("multipart/form-data; boundary=") + assert b"text_field" in body + assert b"plain text value" in body + assert b"file_field" in body + assert b"bundle.tar.gz" in body + assert b"binary content" in body + assert b"application/x-tar" in body + + +class TestPrepareDeployMetadata: + @pytest.fixture + def temp_git_repo(self): + """Create a minimal git repo for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + subprocess.run(["git", "init"], cwd=tmpdir, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmpdir, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmpdir, check=True) + + test_file = Path(tmpdir) / "test.txt" + test_file.write_text("test") + subprocess.run(["git", "add", "."], cwd=tmpdir, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "test"], cwd=tmpdir, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/user/repo.git"], + cwd=tmpdir, + check=True, + capture_output=True, + ) + + yield tmpdir + + def test_prepare_metadata_no_metadata_flag(self, temp_git_repo): + from rsconnect.main import prepare_deploy_metadata + + result = prepare_deploy_metadata(temp_git_repo, tuple(), True, "2025.11.0") + assert result is None + + def test_prepare_metadata_old_server_no_cli_overrides(self, temp_git_repo): + from rsconnect.main import prepare_deploy_metadata + + result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2024.01.0") + assert result is None + + def test_prepare_metadata_new_server(self, temp_git_repo): + from rsconnect.main import prepare_deploy_metadata + + result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2025.11.0") + assert result is not None + assert result["source"] == "git" + assert "source_commit" in result + assert "source_branch" in result + assert result["source_repo"] == "https://github.com/user/repo.git" + + def test_prepare_metadata_cli_overrides(self, temp_git_repo): + from rsconnect.main import prepare_deploy_metadata + + # CLI overrides force metadata even on old servers + result = prepare_deploy_metadata( + temp_git_repo, ("source=custom", "custom_key=custom_value"), False, "2024.01.0" + ) + assert result is not None + assert result["source"] == "custom" + assert result["custom_key"] == "custom_value" + + def test_prepare_metadata_cli_clears_value(self, temp_git_repo): + from rsconnect.main import prepare_deploy_metadata + + # Empty value should clear the key + result = prepare_deploy_metadata(temp_git_repo, ("source_repo=",), False, "2025.11.0") + assert result is not None + assert "source_repo" not in result # Cleared by empty value + assert "source" in result # Still detected + assert "source_commit" in result # Still detected + + +class TestIntegration: + """Integration tests for the full workflow.""" + + def test_app_upload_signature_accepts_metadata(self): + """Test that app_upload accepts metadata parameter.""" + from inspect import signature + + from rsconnect.api import RSConnectClient + + # Check that app_upload has metadata parameter + sig = signature(RSConnectClient.app_upload) + assert "metadata" in sig.parameters From 4c3ecda12319212938fad0147b13c0a9e68dc70a Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 4 Dec 2025 16:36:57 -0500 Subject: [PATCH 2/7] :nail_care: --- rsconnect/git_metadata.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rsconnect/git_metadata.py b/rsconnect/git_metadata.py index 7a9e548e..a5480e19 100644 --- a/rsconnect/git_metadata.py +++ b/rsconnect/git_metadata.py @@ -4,9 +4,7 @@ from __future__ import annotations -import os import subprocess -from os.path import abspath, dirname, exists, join from typing import Optional from urllib.parse import urlparse From c6a48673b9302dc468f96a1357b6fb1e54539d8f Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 4 Dec 2025 16:37:23 -0500 Subject: [PATCH 3/7] Fix version check --- rsconnect/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index e46e468e..cc8d4524 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -380,7 +380,7 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool: """ Check if the server version supports git metadata in bundle uploads. - Git metadata support was added in Connect 2025.11.0. + Git metadata support was added in Connect 2025.12.0. :param server_version: The Connect server version string :return: True if the server supports git metadata, False otherwise @@ -389,7 +389,7 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool: return False try: - return compare_semvers(server_version, "2025.11.0") >= 0 + return compare_semvers(server_version, "2025.11.0") > 0 except Exception: # If we can't parse the version, assume it doesn't support it logger.debug(f"Unable to parse server version: {server_version}") From cba9af86d5af1b201a9616f859bdb46de5d4f5ba Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Thu, 4 Dec 2025 16:48:52 -0500 Subject: [PATCH 4/7] :nail_care: --- tests/test_git_metadata.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index fa90a7ac..049bfbc9 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -2,11 +2,9 @@ Tests for git metadata detection and integration """ -import os import subprocess import tempfile from pathlib import Path -from unittest.mock import Mock, patch import pytest From 95d35e99b2ded39c4400e020e790a7dc9b4f2405 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Fri, 5 Dec 2025 13:12:33 -0500 Subject: [PATCH 5/7] Renaming --- rsconnect/api.py | 4 ++-- tests/test_git_metadata.py | 12 +++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index cc8d4524..d84e05b5 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -517,7 +517,7 @@ def content_create(self, name: str) -> ContentItemV1: response = self._server.handle_bad_response(response) return response - def content_upload_bundle( + def upload_bundle( self, content_guid: str, tarball: typing.IO[bytes], metadata: Optional[dict[str, str]] = None ) -> BundleMetadata: """ @@ -651,7 +651,7 @@ def deploy( result = self._server.handle_bad_response(result) app["title"] = app_title - app_bundle = self.content_upload_bundle(app_guid, tarball, metadata=metadata) + app_bundle = self.upload_bundle(app_guid, tarball, metadata=metadata) task = self.content_deploy(app_guid, app_bundle["id"], activate=activate) diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index 049bfbc9..8cb1ec1e 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -56,9 +56,7 @@ def git_repo(self): test_file = Path(tmpdir) / "test.txt" test_file.write_text("test content") subprocess.run(["git", "add", "."], cwd=tmpdir, check=True, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "Initial commit"], cwd=tmpdir, check=True, capture_output=True - ) + subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=tmpdir, check=True, capture_output=True) # Add a remote subprocess.run( @@ -243,12 +241,12 @@ def test_prepare_metadata_cli_clears_value(self, temp_git_repo): class TestIntegration: """Integration tests for the full workflow.""" - def test_app_upload_signature_accepts_metadata(self): - """Test that app_upload accepts metadata parameter.""" + def test_upload_bundle_signature_accepts_metadata(self): + """Test that upload_bundle accepts metadata parameter.""" from inspect import signature from rsconnect.api import RSConnectClient - # Check that app_upload has metadata parameter - sig = signature(RSConnectClient.app_upload) + # Check that upload_bundle has metadata parameter + sig = signature(RSConnectClient.upload_bundle) assert "metadata" in sig.parameters From e57a14f0f020631e255972ac6e98d2f7e965a873 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 8 Dec 2025 11:04:43 -0500 Subject: [PATCH 6/7] Fix the rest of the version checks --- docs/CHANGELOG.md | 2 +- rsconnect/main.py | 6 +++--- tests/test_git_metadata.py | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 224f8c27..4af2e19f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -38,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tool that returns parameter schemas for any rsconnect command, allowing LLMs to more easily construct valid CLI commands. -- You can now deploy Holoviz Panel applications. This requires Posit Connect release 2025.11.0 +- You can now deploy Holoviz Panel applications. This requires Posit Connect release 2025.12.0 or later. Use `rsconnect deploy panel` to deploy, or `rsconnect write-manifest panel` to create a manifest file. diff --git a/rsconnect/main.py b/rsconnect/main.py index 7d6f5795..dfc1379e 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -64,6 +64,7 @@ RSConnectExecutor, RSConnectServer, SPCSConnectServer, + server_supports_git_metadata, ) from .bundle import ( default_title_from_manifest, @@ -88,6 +89,7 @@ ) from .environment import Environment, fake_module_file_from_directory from .exception import RSConnectException +from .git_metadata import detect_git_metadata from .json_web_token import ( TokenGenerator, parse_client_response, @@ -109,8 +111,6 @@ ) from .shiny_express import escape_to_var_name, is_express_app from .utils_package import fix_starlette_requirements -from .git_metadata import detect_git_metadata -from .api import server_supports_git_metadata T = TypeVar("T") P = ParamSpec("P") @@ -368,7 +368,7 @@ def content_args(func: Callable[P, T]) -> Callable[P, T]: "Include metadata key-value pair with the bundle upload. " "Use format: key=value. May be specified multiple times. " "Use key= (empty value) to clear a detected value. " - "Forces metadata upload even on older servers that don't officially support it. [v2025.11.0+]" + "Forces metadata upload even on older servers that don't officially support it. [v2025.12.0+]" ), ) @click.option( diff --git a/tests/test_git_metadata.py b/tests/test_git_metadata.py index 8cb1ec1e..811ea7a5 100644 --- a/tests/test_git_metadata.py +++ b/tests/test_git_metadata.py @@ -132,8 +132,8 @@ def test_server_supports_git_metadata(self): assert server_supports_git_metadata("2024.01.0") is False assert server_supports_git_metadata("2025.10.0") is False - # Exact version - supported - assert server_supports_git_metadata("2025.11.0") is True + # Exact version - nope + assert server_supports_git_metadata("2025.11.0") is False # Newer version - supported assert server_supports_git_metadata("2025.12.0") is True @@ -197,7 +197,7 @@ def temp_git_repo(self): def test_prepare_metadata_no_metadata_flag(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(temp_git_repo, tuple(), True, "2025.11.0") + result = prepare_deploy_metadata(temp_git_repo, tuple(), True, "2025.12.0") assert result is None def test_prepare_metadata_old_server_no_cli_overrides(self, temp_git_repo): @@ -209,7 +209,7 @@ def test_prepare_metadata_old_server_no_cli_overrides(self, temp_git_repo): def test_prepare_metadata_new_server(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata - result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2025.11.0") + result = prepare_deploy_metadata(temp_git_repo, tuple(), False, "2025.12.0") assert result is not None assert result["source"] == "git" assert "source_commit" in result @@ -231,7 +231,7 @@ def test_prepare_metadata_cli_clears_value(self, temp_git_repo): from rsconnect.main import prepare_deploy_metadata # Empty value should clear the key - result = prepare_deploy_metadata(temp_git_repo, ("source_repo=",), False, "2025.11.0") + result = prepare_deploy_metadata(temp_git_repo, ("source_repo=",), False, "2.0") assert result is not None assert "source_repo" not in result # Cleared by empty value assert "source" in result # Still detected From 91f9ee872a5151c13a3d7981e1875f34a47e5929 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Mon, 8 Dec 2025 11:08:42 -0500 Subject: [PATCH 7/7] :newspaper: --- docs/CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4af2e19f..5f4071e5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,15 @@ 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 + +- 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) + ## [1.28.2] - 2025-12-05 ### Fixed @@ -38,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tool that returns parameter schemas for any rsconnect command, allowing LLMs to more easily construct valid CLI commands. -- You can now deploy Holoviz Panel applications. This requires Posit Connect release 2025.12.0 +- You can now deploy Holoviz Panel applications. This requires Posit Connect release 2025.11.0 or later. Use `rsconnect deploy panel` to deploy, or `rsconnect write-manifest panel` to create a manifest file.