99import datetime
1010import hashlib
1111import hmac
12+ import json
1213import os
1314import re
1415import sys
5556from .certificates import read_certificate_file
5657from .environment import fake_module_file_from_directory
5758from .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+ )
5967from .log import cls_logged , connect_logger , console_logger , logger
6068from .metadata import AppStore , ServerStore
6169from .models import (
7684)
7785from .snowflake import generate_jwt , get_parameters
7886from .timeouts import get_task_timeout , get_task_timeout_help_message
87+ from .utils_package import compare_semvers
7988
8089if 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.12.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+
370399class RSConnectClient (HTTPServer ):
371400 def __init__ (self , server : Union [RSConnectServer , SPCSConnectServer ], cookies : Optional [CookieJar ] = None ):
372401 if cookies is None :
@@ -496,11 +525,34 @@ def content_create(self, name: str) -> ContentItemV1:
496525 response = self ._server .handle_bad_response (response )
497526 return response
498527
499- def content_upload_bundle (self , content_guid : str , tarball : typing .IO [bytes ]) -> BundleMetadata :
500- response = cast (
501- Union [BundleMetadata , HTTPResponse ], self .post ("v1/content/%s/bundles" % content_guid , body = tarball )
502- )
503- response = self ._server .handle_bad_response (response )
528+ def upload_bundle (
529+ self , content_guid : str , tarball : typing .IO [bytes ], metadata : Optional [dict [str , str ]] = None
530+ ) -> BundleMetadata :
531+ """
532+ Upload a bundle to the server.
533+
534+ :param app_id: Application ID
535+ :param tarball: Bundle tarball file object
536+ :param metadata: Optional metadata dictionary (e.g., git metadata)
537+ :return: ContentItemV0 with bundle information
538+ """
539+ if metadata :
540+ # Use multipart form upload when metadata is provided
541+ tarball_content = tarball .read ()
542+ fields = {
543+ "archive" : ("bundle.tar.gz" , tarball_content , "application/x-tar" ),
544+ "metadata" : json .dumps (metadata ),
545+ }
546+ body , content_type = create_multipart_form_data (fields )
547+ response = cast (
548+ Union [BundleMetadata , HTTPResponse ],
549+ self .post ("v1/content/%s/bundles" % content_guid , body = body , headers = {"Content-Type" : content_type }),
550+ )
551+ else :
552+ response = cast (
553+ Union [BundleMetadata , HTTPResponse ], self .post ("v1/content/%s/bundles" % content_guid , body = tarball )
554+ )
555+ response = self ._server .handle_bad_response (response )
504556 return response
505557
506558 def content_update (self , content_guid : str , updates : Mapping [str , str | None ]) -> ContentItemV1 :
@@ -581,6 +633,7 @@ def deploy(
581633 tarball : IO [bytes ],
582634 env_vars : Optional [dict [str , str ]] = None ,
583635 activate : bool = True ,
636+ metadata : Optional [dict [str , str ]] = None ,
584637 ) -> RSConnectClientDeployResult :
585638 if app_id is None :
586639 if app_name is None :
@@ -608,7 +661,7 @@ def deploy(
608661 result = self ._server .handle_bad_response (result )
609662 app ["title" ] = app_title
610663
611- app_bundle = self .content_upload_bundle (app_guid , tarball )
664+ app_bundle = self .upload_bundle (app_guid , tarball , metadata = metadata )
612665
613666 task = self .content_deploy (app_guid , app_bundle ["id" ], activate = activate )
614667
@@ -730,6 +783,7 @@ def __init__(
730783 visibility : Optional [str ] = None ,
731784 disable_env_management : Optional [bool ] = None ,
732785 env_vars : Optional [dict [str , str ]] = None ,
786+ metadata : Optional [dict [str , str ]] = None ,
733787 ) -> None :
734788 self .remote_server : TargetableServer
735789 self .client : RSConnectClient | PositClient
@@ -743,6 +797,7 @@ def __init__(
743797 self .visibility = visibility
744798 self .disable_env_management = disable_env_management
745799 self .env_vars = env_vars
800+ self .metadata = metadata
746801 self .app_mode : AppMode | None = None
747802 self .app_store : AppStore = AppStore (fake_module_file_from_directory (self .path ))
748803 self .app_store_version : int | None = None
@@ -791,6 +846,7 @@ def fromConnectServer(
791846 visibility : Optional [str ] = None ,
792847 disable_env_management : Optional [bool ] = None ,
793848 env_vars : Optional [dict [str , str ]] = None ,
849+ metadata : Optional [dict [str , str ]] = None ,
794850 ):
795851 return cls (
796852 ctx = ctx ,
@@ -813,6 +869,7 @@ def fromConnectServer(
813869 visibility = visibility ,
814870 disable_env_management = disable_env_management ,
815871 env_vars = env_vars ,
872+ metadata = metadata ,
816873 )
817874
818875 def output_overlap_header (self , previous : bool ) -> bool :
@@ -1075,6 +1132,7 @@ def deploy_bundle(self, activate: bool = True):
10751132 self .bundle ,
10761133 self .env_vars ,
10771134 activate = activate ,
1135+ metadata = self .metadata ,
10781136 )
10791137 self .deployed_info = result
10801138 return self
0 commit comments