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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Server uploader - HTTP/HTTPS remote server upload handler for EzCompiler.
9This module provides functionality for uploading files to remote servers
10via HTTP/HTTPS POST requests, with support for authentication, retry logic,
11and SSL verification.
13Note: Protocols layer should not perform logging directly. Logging is handled
14by the service layer that orchestrates upload operations.
15"""
17from __future__ import annotations
19# ///////////////////////////////////////////////////////////////
20# IMPORTS
21# ///////////////////////////////////////////////////////////////
22# Standard library imports
23from pathlib import Path
24from typing import Any
26# Third-party imports
27import requests
29# Local imports
30from ..shared.exceptions import UploadError
31from ..utils.uploader_utils import UploaderUtils
32from .base_uploader import BaseUploader
34# ///////////////////////////////////////////////////////////////
35# CLASSES
36# ///////////////////////////////////////////////////////////////
39class ServerUploader(BaseUploader):
40 """
41 Uploader for server operations via HTTP/HTTPS.
43 Handles uploading files to remote servers using HTTP POST requests with
44 support for authentication, SSL verification, and automatic retry logic.
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)
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 """
62 # ////////////////////////////////////////////////
63 # INITIALIZATION
64 # ////////////////////////////////////////////////
66 def __init__(self, config: dict[str, Any] | None = None) -> None:
67 """
68 Initialize the server uploader.
70 Args:
71 config: Optional configuration dictionary with server settings
72 """
73 default_config = UploaderUtils.get_default_server_config()
75 if config:
76 default_config.update(config)
78 super().__init__(default_config)
80 # ////////////////////////////////////////////////
81 # PUBLIC METHODS
82 # ////////////////////////////////////////////////
84 def get_uploader_name(self) -> str:
85 """
86 Get the name of this uploader.
88 Returns:
89 str: Name of the uploader
90 """
91 return "Server Uploader"
93 def upload(self, source_path: Path, destination: str) -> None:
94 """
95 Upload a file to a remote server.
97 Args:
98 source_path: Path to the source file
99 destination: Destination path on the server
101 Raises:
102 UploadError: If upload fails after all retry attempts
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)
111 if not source_path.is_file():
112 raise UploadError(
113 "Server uploader only supports single files, not directories"
114 )
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
127 # All retries failed
128 raise UploadError(
129 f"Server upload failed after {self._config['retry_attempts']} attempts: {last_error}"
130 ) from last_error
132 except UploadError:
133 raise
134 except Exception as e:
135 raise UploadError(f"Server upload failed: {e}") from e
137 def _test_connection(self) -> bool:
138 """
139 Test the connection to the server.
141 Returns:
142 bool: True if connection is successful, False otherwise
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()
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 )
160 return response.ok
161 except Exception:
162 return False
164 # ////////////////////////////////////////////////
165 # PRIVATE METHODS
166 # ////////////////////////////////////////////////
168 def _perform_upload(self, source_path: Path, destination: str) -> None:
169 """
170 Perform the actual upload operation.
172 Args:
173 source_path: Source file path
174 destination: Destination path on server
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()
183 with open(source_path, "rb") as file:
184 files = {"file": (source_path.name, file, "application/octet-stream")}
185 data = {"destination": destination}
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 )
197 if not response.ok:
198 raise UploadError(
199 f"Server returned error {response.status_code}: {response.text}"
200 )
202 def _build_upload_url(self, _destination: str) -> str:
203 """
204 Build the complete upload URL.
206 Args:
207 _destination: Destination path (unused, for future extensions)
209 Returns:
210 str: Complete upload URL
211 """
212 base_url = self._config["server_url"].rstrip("/")
213 return f"{base_url}/upload"
215 def _prepare_headers(self) -> dict[str, str]:
216 """
217 Prepare HTTP headers for the upload request.
219 Returns:
220 dict[str, str]: Headers dictionary
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 }
230 if self._config["api_key"]:
231 headers["Authorization"] = f"Bearer {self._config['api_key']}"
233 return headers
235 def _prepare_auth(self) -> tuple[str, str] | None:
236 """
237 Prepare authentication for the upload request.
239 Returns:
240 tuple[str, str] | None: Basic auth tuple or None
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
249 # ////////////////////////////////////////////////
250 # VALIDATION METHODS
251 # ////////////////////////////////////////////////
253 def _validate_config(self) -> None:
254 """
255 Validate server uploader configuration.
257 Raises:
258 UploadError: If configuration is invalid
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 ]
275 for key in required_keys:
276 if key not in self._config:
277 raise UploadError(f"Missing required configuration key: {key}")
279 # Validate server URL using UploaderUtils
280 UploaderUtils.validate_server_url(self._config["server_url"])
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")
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")
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")
300 if not isinstance(self._config["verify_ssl"], bool):
301 raise UploadError("verify_ssl must be a boolean")