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.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+
370399class 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
0 commit comments