Coverage for src / ezcompiler / adapters / server_uploader.py: 16.19%

79 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 06:49 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVER_UPLOADER - Remote server uploader implementation 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Server uploader - HTTP/HTTPS remote server upload handler for EzCompiler. 

8 

9This module provides functionality for uploading files to remote servers 

10via HTTP/HTTPS POST requests, with support for authentication, retry logic, 

11and SSL verification. 

12 

13Note: Protocols layer should not perform logging directly. Logging is handled 

14by the service layer that orchestrates upload operations. 

15""" 

16 

17from __future__ import annotations 

18 

19# /////////////////////////////////////////////////////////////// 

20# IMPORTS 

21# /////////////////////////////////////////////////////////////// 

22# Standard library imports 

23from pathlib import Path 

24from typing import Any 

25 

26# Third-party imports 

27import requests 

28 

29# Local imports 

30from ..shared.exceptions import UploadError 

31from ..utils.uploader_utils import UploaderUtils 

32from .base_uploader import BaseUploader 

33 

34# /////////////////////////////////////////////////////////////// 

35# CLASSES 

36# /////////////////////////////////////////////////////////////// 

37 

38 

39class ServerUploader(BaseUploader): 

40 """ 

41 Uploader for server operations via HTTP/HTTPS. 

42 

43 Handles uploading files to remote servers using HTTP POST requests with 

44 support for authentication, SSL verification, and automatic retry logic. 

45 

46 Configuration keys: 

47 server_url (str): Base URL of the upload server 

48 username (str): Username for basic authentication (default: "") 

49 password (str): Password for basic authentication (default: "") 

50 api_key (str): API key for bearer token authentication (default: "") 

51 timeout (int|float): Request timeout in seconds (default: 30) 

52 verify_ssl (bool): Verify SSL certificates (default: True) 

53 chunk_size (int): Chunk size for uploads (default: 8192) 

54 retry_attempts (int): Number of retry attempts (default: 3) 

55 

56 Example: 

57 >>> config = {"server_url": "https://example.com", "api_key": "abc123"} 

58 >>> uploader = ServerUploader(config) 

59 >>> uploader.upload(Path("file.zip"), "/uploads/file.zip") 

60 """ 

61 

62 # //////////////////////////////////////////////// 

63 # INITIALIZATION 

64 # //////////////////////////////////////////////// 

65 

66 def __init__(self, config: dict[str, Any] | None = None) -> None: 

67 """ 

68 Initialize the server uploader. 

69 

70 Args: 

71 config: Optional configuration dictionary with server settings 

72 """ 

73 default_config = UploaderUtils.get_default_server_config() 

74 

75 if config: 

76 default_config.update(config) 

77 

78 super().__init__(default_config) 

79 

80 # //////////////////////////////////////////////// 

81 # PUBLIC METHODS 

82 # //////////////////////////////////////////////// 

83 

84 def get_uploader_name(self) -> str: 

85 """ 

86 Get the name of this uploader. 

87 

88 Returns: 

89 str: Name of the uploader 

90 """ 

91 return "Server Uploader" 

92 

93 def upload(self, source_path: Path, destination: str) -> None: 

94 """ 

95 Upload a file to a remote server. 

96 

97 Args: 

98 source_path: Path to the source file 

99 destination: Destination path on the server 

100 

101 Raises: 

102 UploadError: If upload fails after all retry attempts 

103 

104 Note: 

105 Only supports single files, not directories. 

106 Automatically retries on failure based on retry_attempts config. 

107 """ 

108 try: 

109 self._validate_source_path(source_path) 

110 

111 if not source_path.is_file(): 

112 raise UploadError( 

113 "Server uploader only supports single files, not directories" 

114 ) 

115 

116 # Retry logic 

117 last_error = None 

118 for attempt in range(self._config["retry_attempts"]): 

119 try: 

120 self._perform_upload(source_path, destination) 

121 return # Success 

122 except Exception as e: 

123 last_error = e 

124 if attempt == self._config["retry_attempts"] - 1: 

125 break 

126 

127 # All retries failed 

128 raise UploadError( 

129 f"Server upload failed after {self._config['retry_attempts']} attempts: {last_error}" 

130 ) from last_error 

131 

132 except UploadError: 

133 raise 

134 except Exception as e: 

135 raise UploadError(f"Server upload failed: {e}") from e 

136 

137 def _test_connection(self) -> bool: 

138 """ 

139 Test the connection to the server. 

140 

141 Returns: 

142 bool: True if connection is successful, False otherwise 

143 

144 Note: 

145 Attempts to reach /health endpoint on the server. 

146 """ 

147 try: 

148 test_url = f"{self._config['server_url'].rstrip('/')}/health" 

149 headers = self._prepare_headers() 

150 auth = self._prepare_auth() 

151 

152 response = requests.get( 

153 test_url, 

154 headers=headers, 

155 auth=auth, 

156 timeout=self._config["timeout"], 

157 verify=self._config["verify_ssl"], 

158 ) 

159 

160 return response.ok 

161 except Exception: 

162 return False 

163 

164 # //////////////////////////////////////////////// 

165 # PRIVATE METHODS 

166 # //////////////////////////////////////////////// 

167 

168 def _perform_upload(self, source_path: Path, destination: str) -> None: 

169 """ 

170 Perform the actual upload operation. 

171 

172 Args: 

173 source_path: Source file path 

174 destination: Destination path on server 

175 

176 Raises: 

177 UploadError: If server returns error response 

178 """ 

179 upload_url = self._build_upload_url(destination) 

180 headers = self._prepare_headers() 

181 auth = self._prepare_auth() 

182 

183 with open(source_path, "rb") as file: 

184 files = {"file": (source_path.name, file, "application/octet-stream")} 

185 data = {"destination": destination} 

186 

187 response = requests.post( 

188 upload_url, 

189 files=files, 

190 data=data, 

191 headers=headers, 

192 auth=auth, 

193 timeout=self._config["timeout"], 

194 verify=self._config["verify_ssl"], 

195 ) 

196 

197 if not response.ok: 

198 raise UploadError( 

199 f"Server returned error {response.status_code}: {response.text}" 

200 ) 

201 

202 def _build_upload_url(self, _destination: str) -> str: 

203 """ 

204 Build the complete upload URL. 

205 

206 Args: 

207 _destination: Destination path (unused, for future extensions) 

208 

209 Returns: 

210 str: Complete upload URL 

211 """ 

212 base_url = self._config["server_url"].rstrip("/") 

213 return f"{base_url}/upload" 

214 

215 def _prepare_headers(self) -> dict[str, str]: 

216 """ 

217 Prepare HTTP headers for the upload request. 

218 

219 Returns: 

220 dict[str, str]: Headers dictionary 

221 

222 Note: 

223 Includes User-Agent and optional Bearer token authorization. 

224 """ 

225 headers = { 

226 "User-Agent": "EzCompiler/2.0.0", 

227 "Accept": "application/json", 

228 } 

229 

230 if self._config["api_key"]: 

231 headers["Authorization"] = f"Bearer {self._config['api_key']}" 

232 

233 return headers 

234 

235 def _prepare_auth(self) -> tuple[str, str] | None: 

236 """ 

237 Prepare authentication for the upload request. 

238 

239 Returns: 

240 tuple[str, str] | None: Basic auth tuple or None 

241 

242 Note: 

243 Returns (username, password) tuple for basic auth if configured. 

244 """ 

245 if self._config["username"] and self._config["password"]: 

246 return (self._config["username"], self._config["password"]) 

247 return None 

248 

249 # //////////////////////////////////////////////// 

250 # VALIDATION METHODS 

251 # //////////////////////////////////////////////// 

252 

253 def _validate_config(self) -> None: 

254 """ 

255 Validate server uploader configuration. 

256 

257 Raises: 

258 UploadError: If configuration is invalid 

259 

260 Note: 

261 Validates required keys, URL format, and value types/ranges. 

262 Uses UploaderUtils for URL validation. 

263 """ 

264 required_keys = [ 

265 "server_url", 

266 "username", 

267 "password", 

268 "api_key", 

269 "timeout", 

270 "verify_ssl", 

271 "chunk_size", 

272 "retry_attempts", 

273 ] 

274 

275 for key in required_keys: 

276 if key not in self._config: 

277 raise UploadError(f"Missing required configuration key: {key}") 

278 

279 # Validate server URL using UploaderUtils 

280 UploaderUtils.validate_server_url(self._config["server_url"]) 

281 

282 if ( 

283 not isinstance(self._config["timeout"], (int, float)) 

284 or self._config["timeout"] <= 0 

285 ): 

286 raise UploadError("timeout must be a positive number") 

287 

288 if ( 

289 not isinstance(self._config["chunk_size"], int) 

290 or self._config["chunk_size"] <= 0 

291 ): 

292 raise UploadError("chunk_size must be a positive integer") 

293 

294 if ( 

295 not isinstance(self._config["retry_attempts"], int) 

296 or self._config["retry_attempts"] < 0 

297 ): 

298 raise UploadError("retry_attempts must be a non-negative integer") 

299 

300 if not isinstance(self._config["verify_ssl"], bool): 

301 raise UploadError("verify_ssl must be a boolean")