# util/sftp.py"""Wrapper around Paramiko SFTP functionality and related exceptions."""importioimportsocketfrompathlibimportPath,PurePosixPathfromtypingimportAnyimportparamikofrommanagement.modelsimportBackupOptions
[docs]classSftpError(Exception):"""Custom exception for any SFTP-related failures."""
[docs]classSftpClient:"""Wrapper around Paramiko SFTP functionality. If instantiated without overrides, reads settings from BackupOptions(pk=1). Optionally, an overrides dict can supply: host, port, user, auth_method, password, private_key, key_passphrase, local_storage, remote_directory. """def__init__(self,overrides:dict[str,Any]|None=None)->None:"""Initialize the SftpClient. Args: overrides: If provided, a dict of any BackupOptions fields to override. """try:opts=BackupOptions.objects.get(pk=1)exceptBackupOptions.DoesNotExist:opts=Nonedef_get(field:str,default:Any=None)->Any:ifoverridesandfieldinoverrides:returnoverrides[field]ifopts:returngetattr(opts,field)returndefault
ifself.auth_method:valid_methods=BackupOptions.AuthMethod.valuesifself.auth_methodnotinvalid_methods:msg=f'Invalid auth_method: {self.auth_method}'raiseSftpError(msg)if(self.auth_method==BackupOptions.AuthMethod.PASSWORDandnotself.password):msg='Password is required for password authentication.'raiseSftpError(msg)if(self.auth_method==BackupOptions.AuthMethod.SSH_KEYandnot(self.private_key_textor'').strip()):msg='Private key is required for SSH-key authentication.'raiseSftpError(msg)
[docs]def_load_private_key(self)->paramiko.PKey:"""Load a Paramiko PKey from the stored private_key_text and passphrase. Returns: A Paramiko PKey object. Raises: SftpError: If key loading fails or no key provided. """ifnotself.private_key_text:msg='No private key provided.'raiseSftpError(msg)try:key_stream=io.StringIO(self.private_key_text)returnparamiko.RSAKey.from_private_key(key_stream,password=self.passphraseorNone)exceptparamiko.SSHExceptionase:msg=f'Failed to load private key: {e}'raiseSftpError(msg)frome
[docs]def_connect_sftp(self)->tuple[paramiko.Transport,paramiko.SFTPClient]:"""Establish an SFTP connection and return the Transport and SFTPClient. Returns: A tuple of (Transport, SFTPClient). Raises: SftpError: If no auth_method is set or authentication/SSH errors occur. """ifnotself.auth_method:msg='No SFTP configured; cannot connect.'raiseSftpError(msg)try:transport=paramiko.Transport((self.host,self.port))ifself.auth_method==BackupOptions.AuthMethod.PASSWORD:transport.connect(username=self.username,password=self.password)else:pkey=self._load_private_key()transport.connect(username=self.username,pkey=pkey)sftp=paramiko.SFTPClient.from_transport(transport)ifsftpisNone:transport.close()msg='Authentication failed.'raiseSftpError(msg)returntransport,sftp# noqa: TRY300exceptparamiko.AuthenticationExceptionase:msg='Authentication failed.'raiseSftpError(msg)fromeexceptparamiko.SSHExceptionase:msg=f'SSH error: {e}'raiseSftpError(msg)fromeexcept(socket.gaierror,OSError)ase:msg=f'Could not connect to {self.host}:{self.port} – {e}'raiseSftpError(msg)frome
[docs]deftest_connection(self)->None:"""Attempt an SFTP connection with the current settings. Raises: SftpError: If no auth_method, authentication fails, or any SSH error. """transport:paramiko.Transport|None=Nonetry:transport,sftp=self._connect_sftp()sftp.close()finally:iftransportandtransport.is_active():transport.close()
[docs]defupload_file(self,local_filepath:Path,remote_path:str)->None:# noqa: C901"""Upload a single local file to the remote_path via SFTP. Args: local_filepath: Path to the local file to upload. remote_path: Full remote path (including filename) at the server. Raises: SftpError: If no auth_method, local file missing, or any SSH/SFTP error. """ifnotself.auth_method:msg='No SFTP configured; cannot upload.'raiseSftpError(msg)ifnotlocal_filepath.exists()ornotlocal_filepath.is_file():msg=f'Local file does not exist: {local_filepath}'raiseSftpError(msg)transport:paramiko.Transport|None=Nonetry:transport,sftp=self._connect_sftp()# Ensure remote directory exists (mkdir -p behavior)remote_dir=PurePosixPath(remote_path).parentifstr(remote_dir)notin('','.'):path_accum=PurePosixPath(remote_dir.parts[0])forpartinremote_dir.parts[1:]:path_accum/=parttry:sftp.stat(str(path_accum))exceptOSError:try:sftp.mkdir(str(path_accum))exceptparamiko.SSHExceptionase:msg=f'Failed to create remote directory {path_accum}: {e}'raiseSftpError(msg)fromeexceptPermissionErrorase:msg=f'Failed to create remote directory {path_accum}: {e}'raiseSftpError(msg)frometry:sftp.put(str(local_filepath),remote_path)exceptparamiko.SSHExceptionase:msg=f'SSH error during upload: {e}'raiseSftpError(msg)fromesftp.close()exceptparamiko.SSHExceptionase:msg=f'Upload failed: {e}'raiseSftpError(msg)fromefinally:iftransportandtransport.is_active():transport.close()