Skip to content

Commit f07986f

Browse files
Claude's first pass at adding bundle metadata support
1 parent f7459cb commit f07986f

File tree

5 files changed

+693
-8
lines changed

5 files changed

+693
-8
lines changed

rsconnect/api.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import datetime
1010
import hashlib
1111
import hmac
12+
import json
1213
import os
1314
import re
1415
import sys
@@ -55,7 +56,14 @@
5556
from .certificates import read_certificate_file
5657
from .environment import fake_module_file_from_directory
5758
from .exception import DeploymentFailedException, RSConnectException
58-
from .http_support import CookieJar, HTTPResponse, HTTPServer, JsonData, append_to_path
59+
from .http_support import (
60+
CookieJar,
61+
HTTPResponse,
62+
HTTPServer,
63+
JsonData,
64+
append_to_path,
65+
create_multipart_form_data,
66+
)
5967
from .log import cls_logged, connect_logger, console_logger, logger
6068
from .metadata import AppStore, ServerStore
6169
from .models import (
@@ -76,6 +84,7 @@
7684
)
7785
from .snowflake import generate_jwt, get_parameters
7886
from .timeouts import get_task_timeout, get_task_timeout_help_message
87+
from .utils_package import compare_semvers
7988

8089
if TYPE_CHECKING:
8190
import logging
@@ -367,6 +376,26 @@ class RSConnectClientDeployResult(TypedDict):
367376
title: str | None
368377

369378

379+
def server_supports_git_metadata(server_version: Optional[str]) -> bool:
380+
"""
381+
Check if the server version supports git metadata in bundle uploads.
382+
383+
Git metadata support was added in Connect 2025.11.0.
384+
385+
:param server_version: The Connect server version string
386+
:return: True if the server supports git metadata, False otherwise
387+
"""
388+
if not server_version:
389+
return False
390+
391+
try:
392+
return compare_semvers(server_version, "2025.11.0") >= 0
393+
except Exception:
394+
# If we can't parse the version, assume it doesn't support it
395+
logger.debug(f"Unable to parse server version: {server_version}")
396+
return False
397+
398+
370399
class RSConnectClient(HTTPServer):
371400
def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None):
372401
if cookies is None:
@@ -488,11 +517,34 @@ def content_create(self, name: str) -> ContentItemV1:
488517
response = self._server.handle_bad_response(response)
489518
return response
490519

491-
def content_upload_bundle(self, content_guid: str, tarball: typing.IO[bytes]) -> BundleMetadata:
492-
response = cast(
493-
Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball)
494-
)
495-
response = self._server.handle_bad_response(response)
520+
def content_upload_bundle(
521+
self, content_guid: str, tarball: typing.IO[bytes], metadata: Optional[dict[str, str]] = None
522+
) -> BundleMetadata:
523+
"""
524+
Upload a bundle to the server.
525+
526+
:param app_id: Application ID
527+
:param tarball: Bundle tarball file object
528+
:param metadata: Optional metadata dictionary (e.g., git metadata)
529+
:return: ContentItemV0 with bundle information
530+
"""
531+
if metadata:
532+
# Use multipart form upload when metadata is provided
533+
tarball_content = tarball.read()
534+
fields = {
535+
"archive": ("bundle.tar.gz", tarball_content, "application/x-tar"),
536+
"metadata": json.dumps(metadata),
537+
}
538+
body, content_type = create_multipart_form_data(fields)
539+
response = cast(
540+
Union[BundleMetadata, HTTPResponse],
541+
self.post("v1/content/%s/bundles" % content_guid, body=body, headers={"Content-Type": content_type}),
542+
)
543+
else:
544+
response = cast(
545+
Union[BundleMetadata, HTTPResponse], self.post("v1/content/%s/bundles" % content_guid, body=tarball)
546+
)
547+
response = self._server.handle_bad_response(response)
496548
return response
497549

498550
def content_update(self, content_guid: str, updates: Mapping[str, str | None]) -> ContentItemV1:
@@ -571,6 +623,7 @@ def deploy(
571623
tarball: IO[bytes],
572624
env_vars: Optional[dict[str, str]] = None,
573625
activate: bool = True,
626+
metadata: Optional[dict[str, str]] = None,
574627
) -> RSConnectClientDeployResult:
575628
if app_id is None:
576629
if app_name is None:
@@ -598,7 +651,7 @@ def deploy(
598651
result = self._server.handle_bad_response(result)
599652
app["title"] = app_title
600653

601-
app_bundle = self.content_upload_bundle(app_guid, tarball)
654+
app_bundle = self.content_upload_bundle(app_guid, tarball, metadata=metadata)
602655

603656
task = self.content_deploy(app_guid, app_bundle["id"], activate=activate)
604657

@@ -724,6 +777,7 @@ def __init__(
724777
visibility: Optional[str] = None,
725778
disable_env_management: Optional[bool] = None,
726779
env_vars: Optional[dict[str, str]] = None,
780+
metadata: Optional[dict[str, str]] = None,
727781
) -> None:
728782
self.remote_server: TargetableServer
729783
self.client: RSConnectClient | PositClient
@@ -737,6 +791,7 @@ def __init__(
737791
self.visibility = visibility
738792
self.disable_env_management = disable_env_management
739793
self.env_vars = env_vars
794+
self.metadata = metadata
740795
self.app_mode: AppMode | None = None
741796
self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path))
742797
self.app_store_version: int | None = None
@@ -785,6 +840,7 @@ def fromConnectServer(
785840
visibility: Optional[str] = None,
786841
disable_env_management: Optional[bool] = None,
787842
env_vars: Optional[dict[str, str]] = None,
843+
metadata: Optional[dict[str, str]] = None,
788844
):
789845
return cls(
790846
ctx=ctx,
@@ -807,6 +863,7 @@ def fromConnectServer(
807863
visibility=visibility,
808864
disable_env_management=disable_env_management,
809865
env_vars=env_vars,
866+
metadata=metadata,
810867
)
811868

812869
def output_overlap_header(self, previous: bool) -> bool:
@@ -1069,6 +1126,7 @@ def deploy_bundle(self, activate: bool = True):
10691126
self.bundle,
10701127
self.env_vars,
10711128
activate=activate,
1129+
metadata=self.metadata,
10721130
)
10731131
self.deployed_info = result
10741132
return self

rsconnect/git_metadata.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""
2+
Git metadata detection utilities for bundle uploads
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import os
8+
import subprocess
9+
from os.path import abspath, dirname, exists, join
10+
from typing import Optional
11+
from urllib.parse import urlparse
12+
13+
from .log import logger
14+
15+
16+
def _run_git_command(args: list[str], cwd: str) -> Optional[str]:
17+
"""
18+
Run a git command and return its output.
19+
20+
:param args: git command arguments
21+
:param cwd: working directory
22+
:return: command output or None if command failed
23+
"""
24+
try:
25+
result = subprocess.run(
26+
["git"] + args,
27+
cwd=cwd,
28+
capture_output=True,
29+
text=True,
30+
timeout=5,
31+
)
32+
if result.returncode == 0:
33+
return result.stdout.strip()
34+
return None
35+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
36+
return None
37+
38+
39+
def is_git_repo(directory: str) -> bool:
40+
"""
41+
Check if directory is inside a git repository.
42+
43+
:param directory: directory to check
44+
:return: True if inside a git repo, False otherwise
45+
"""
46+
result = _run_git_command(["rev-parse", "--git-dir"], directory)
47+
return result is not None
48+
49+
50+
def has_uncommitted_changes(directory: str) -> bool:
51+
"""
52+
Check if the git repository has uncommitted changes.
53+
54+
:param directory: directory to check
55+
:return: True if there are uncommitted changes
56+
"""
57+
# Check for staged and unstaged changes
58+
result = _run_git_command(["status", "--porcelain"], directory)
59+
return bool(result)
60+
61+
62+
def get_git_commit(directory: str) -> Optional[str]:
63+
"""
64+
Get the current git commit SHA.
65+
66+
:param directory: directory to check
67+
:return: commit SHA or None
68+
"""
69+
return _run_git_command(["rev-parse", "HEAD"], directory)
70+
71+
72+
def get_git_branch(directory: str) -> Optional[str]:
73+
"""
74+
Get the current git branch name or tag.
75+
76+
:param directory: directory to check
77+
:return: branch/tag name or None
78+
"""
79+
# First try to get branch name
80+
branch = _run_git_command(["rev-parse", "--abbrev-ref", "HEAD"], directory)
81+
82+
# If we're in detached HEAD state, try to get tag
83+
if branch == "HEAD":
84+
tag = _run_git_command(["describe", "--exact-match", "--tags"], directory)
85+
if tag:
86+
return tag
87+
88+
return branch
89+
90+
91+
def get_git_remote_url(directory: str, remote: str = "origin") -> Optional[str]:
92+
"""
93+
Get the URL of a git remote.
94+
95+
:param directory: directory to check
96+
:param remote: remote name (default: "origin")
97+
:return: remote URL or None
98+
"""
99+
return _run_git_command(["remote", "get-url", remote], directory)
100+
101+
102+
def normalize_git_url_to_https(url: Optional[str]) -> Optional[str]:
103+
"""
104+
Normalize a git URL to HTTPS format.
105+
106+
Converts SSH URLs like git@github.com:user/repo.git to
107+
https://github.com/user/repo.git
108+
109+
:param url: git URL to normalize
110+
:return: normalized HTTPS URL or original if already HTTPS/not recognized
111+
"""
112+
if not url:
113+
return url
114+
115+
# Already HTTPS
116+
if url.startswith("https://"):
117+
return url
118+
119+
# Handle git@ SSH format
120+
if url.startswith("git@"):
121+
# git@github.com:user/repo.git -> https://github.com/user/repo.git
122+
# Remove git@ prefix
123+
url = url[4:]
124+
# Replace first : with /
125+
url = url.replace(":", "/", 1)
126+
# Add https://
127+
return f"https://{url}"
128+
129+
# Handle ssh:// format
130+
if url.startswith("ssh://"):
131+
# ssh://git@github.com/user/repo.git -> https://github.com/user/repo.git
132+
parsed = urlparse(url)
133+
if parsed.hostname:
134+
path = parsed.path
135+
return f"https://{parsed.hostname}{path}"
136+
137+
# Return as-is if we can't normalize
138+
return url
139+
140+
141+
def detect_git_metadata(directory: str, remote: str = "origin") -> dict[str, str]:
142+
"""
143+
Detect git metadata for the given directory.
144+
145+
:param directory: directory to inspect
146+
:param remote: git remote name to use (default: "origin")
147+
:return: dictionary with source, source_repo, source_branch, source_commit keys
148+
"""
149+
metadata: dict[str, str] = {}
150+
151+
if not is_git_repo(directory):
152+
logger.debug(f"Directory {directory} is not a git repository")
153+
return metadata
154+
155+
# Get commit SHA
156+
commit = get_git_commit(directory)
157+
if commit:
158+
# Check for uncommitted changes
159+
if has_uncommitted_changes(directory):
160+
commit = f"{commit}-dirty"
161+
metadata["source_commit"] = commit
162+
163+
# Get branch/tag
164+
branch = get_git_branch(directory)
165+
if branch:
166+
metadata["source_branch"] = branch
167+
168+
# Get remote URL and normalize to HTTPS
169+
remote_url = get_git_remote_url(directory, remote)
170+
if remote_url:
171+
normalized_url = normalize_git_url_to_https(remote_url)
172+
if normalized_url:
173+
metadata["source_repo"] = normalized_url
174+
175+
# Always set source to "git" if we got any metadata
176+
if metadata:
177+
metadata["source"] = "git"
178+
logger.debug(f"Detected git metadata: {metadata}")
179+
180+
return metadata

0 commit comments

Comments
 (0)