clonebox 1.1.2__py3-none-any.whl → 1.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Data models for health check system."""
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timedelta
6
+ from enum import Enum
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ class HealthStatus(Enum):
11
+ """Health check status."""
12
+
13
+ HEALTHY = "healthy"
14
+ UNHEALTHY = "unhealthy"
15
+ DEGRADED = "degraded"
16
+ UNKNOWN = "unknown"
17
+ TIMEOUT = "timeout"
18
+
19
+
20
+ class ProbeType(Enum):
21
+ """Type of health probe."""
22
+
23
+ HTTP = "http"
24
+ TCP = "tcp"
25
+ COMMAND = "command"
26
+ SCRIPT = "script"
27
+
28
+
29
+ @dataclass
30
+ class ProbeConfig:
31
+ """Configuration for a health probe."""
32
+
33
+ name: str
34
+ probe_type: ProbeType
35
+ enabled: bool = True
36
+
37
+ # Timing
38
+ timeout_seconds: float = 5.0
39
+ interval_seconds: float = 30.0
40
+ retries: int = 3
41
+ retry_delay_seconds: float = 1.0
42
+
43
+ # HTTP probe
44
+ url: Optional[str] = None
45
+ method: str = "GET"
46
+ expected_status: int = 200
47
+ expected_body: Optional[str] = None
48
+ expected_json: Optional[Dict[str, Any]] = None
49
+ headers: Dict[str, str] = field(default_factory=dict)
50
+
51
+ # TCP probe
52
+ host: str = "localhost"
53
+ port: Optional[int] = None
54
+
55
+ # Command probe
56
+ command: Optional[str] = None
57
+ expected_output: Optional[str] = None
58
+ expected_exit_code: int = 0
59
+
60
+ # Script probe
61
+ script_path: Optional[str] = None
62
+
63
+ # Thresholds
64
+ failure_threshold: int = 3 # Consecutive failures before unhealthy
65
+ success_threshold: int = 1 # Consecutive successes before healthy
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ """Convert to dictionary."""
69
+ return {
70
+ "name": self.name,
71
+ "type": self.probe_type.value,
72
+ "enabled": self.enabled,
73
+ "timeout": self.timeout_seconds,
74
+ "interval": self.interval_seconds,
75
+ "retries": self.retries,
76
+ "url": self.url,
77
+ "method": self.method,
78
+ "expected_status": self.expected_status,
79
+ "expected_body": self.expected_body,
80
+ "expected_json": self.expected_json,
81
+ "headers": self.headers,
82
+ "host": self.host,
83
+ "port": self.port,
84
+ "command": self.command,
85
+ "expected_output": self.expected_output,
86
+ "expected_exit_code": self.expected_exit_code,
87
+ "script_path": self.script_path,
88
+ "failure_threshold": self.failure_threshold,
89
+ "success_threshold": self.success_threshold,
90
+ }
91
+
92
+ @classmethod
93
+ def from_dict(cls, data: Dict[str, Any]) -> "ProbeConfig":
94
+ """Create from dictionary."""
95
+ return cls(
96
+ name=data["name"],
97
+ probe_type=ProbeType(data.get("type", "command")),
98
+ enabled=data.get("enabled", True),
99
+ timeout_seconds=data.get("timeout", 5.0),
100
+ interval_seconds=data.get("interval", 30.0),
101
+ retries=data.get("retries", 3),
102
+ url=data.get("url"),
103
+ method=data.get("method", "GET"),
104
+ expected_status=data.get("expected_status", 200),
105
+ expected_body=data.get("expected_body"),
106
+ expected_json=data.get("expected_json"),
107
+ headers=data.get("headers", {}),
108
+ host=data.get("host", "localhost"),
109
+ port=data.get("port"),
110
+ command=data.get("command") or data.get("exec"),
111
+ expected_output=data.get("expected_output"),
112
+ expected_exit_code=data.get("expected_exit_code", data.get("exit_code", 0)),
113
+ script_path=data.get("script_path") or data.get("path"),
114
+ failure_threshold=data.get("failure_threshold", 3),
115
+ success_threshold=data.get("success_threshold", 1),
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class HealthCheckResult:
121
+ """Result of a health check."""
122
+
123
+ probe_name: str
124
+ status: HealthStatus
125
+ checked_at: datetime
126
+ duration_ms: float
127
+
128
+ message: Optional[str] = None
129
+ error: Optional[str] = None
130
+ details: Dict[str, Any] = field(default_factory=dict)
131
+
132
+ # Response info (for HTTP)
133
+ response_code: Optional[int] = None
134
+ response_body: Optional[str] = None
135
+
136
+ # Command info
137
+ exit_code: Optional[int] = None
138
+ stdout: Optional[str] = None
139
+ stderr: Optional[str] = None
140
+
141
+ def to_dict(self) -> Dict[str, Any]:
142
+ """Convert to dictionary."""
143
+ return {
144
+ "probe_name": self.probe_name,
145
+ "status": self.status.value,
146
+ "checked_at": self.checked_at.isoformat(),
147
+ "duration_ms": self.duration_ms,
148
+ "message": self.message,
149
+ "error": self.error,
150
+ "details": self.details,
151
+ "response_code": self.response_code,
152
+ "exit_code": self.exit_code,
153
+ }
154
+
155
+ @property
156
+ def is_healthy(self) -> bool:
157
+ """Check if result indicates healthy status."""
158
+ return self.status == HealthStatus.HEALTHY
159
+
160
+
161
+ @dataclass
162
+ class VMHealthState:
163
+ """Aggregated health state for a VM."""
164
+
165
+ vm_name: str
166
+ overall_status: HealthStatus
167
+ last_check: datetime
168
+ check_results: List[HealthCheckResult] = field(default_factory=list)
169
+
170
+ # Counters
171
+ consecutive_failures: int = 0
172
+ consecutive_successes: int = 0
173
+ total_checks: int = 0
174
+ total_failures: int = 0
175
+
176
+ def to_dict(self) -> Dict[str, Any]:
177
+ """Convert to dictionary."""
178
+ return {
179
+ "vm_name": self.vm_name,
180
+ "overall_status": self.overall_status.value,
181
+ "last_check": self.last_check.isoformat(),
182
+ "check_results": [r.to_dict() for r in self.check_results],
183
+ "consecutive_failures": self.consecutive_failures,
184
+ "consecutive_successes": self.consecutive_successes,
185
+ "total_checks": self.total_checks,
186
+ "total_failures": self.total_failures,
187
+ }
188
+
189
+ @property
190
+ def failure_rate(self) -> float:
191
+ """Calculate failure rate percentage."""
192
+ if self.total_checks == 0:
193
+ return 0.0
194
+ return (self.total_failures / self.total_checks) * 100
clonebox/importer.py ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ VM Importer - Import VM with path reconfiguration and decryption.
4
+ """
5
+
6
+ import shutil
7
+ import subprocess
8
+ import tarfile
9
+ import tempfile
10
+ import xml.etree.ElementTree as ET
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from cryptography.fernet import Fernet
15
+
16
+ try:
17
+ import libvirt
18
+ except ImportError:
19
+ libvirt = None
20
+
21
+
22
+ class VMImporter:
23
+ """Import VM with disk path reconfiguration."""
24
+
25
+ DEFAULT_DISK_DIR = Path("/var/lib/libvirt/images")
26
+
27
+ def __init__(self, conn_uri: str = "qemu:///system"):
28
+ self.conn_uri = conn_uri
29
+ self._conn = None
30
+
31
+ @property
32
+ def conn(self):
33
+ if self._conn is None:
34
+ if libvirt is None:
35
+ raise RuntimeError("libvirt-python not installed")
36
+ self._conn = libvirt.open(self.conn_uri)
37
+ return self._conn
38
+
39
+ def import_vm(
40
+ self,
41
+ archive_path: Path,
42
+ import_user_data: bool = False,
43
+ import_app_data: bool = False,
44
+ new_name: Optional[str] = None,
45
+ disk_dir: Optional[Path] = None,
46
+ ) -> str:
47
+ """Import VM from archive with full path reconfiguration."""
48
+ disk_dir = disk_dir or self.DEFAULT_DISK_DIR
49
+
50
+ with tempfile.TemporaryDirectory(prefix="clonebox-import-") as tmp_dir:
51
+ tmp_path = Path(tmp_dir)
52
+
53
+ # Extract archive
54
+ with tarfile.open(archive_path) as tar:
55
+ tar.extractall(tmp_path)
56
+
57
+ # Find XML file
58
+ xml_files = list(tmp_path.glob("*.xml"))
59
+ if not xml_files:
60
+ raise FileNotFoundError("No XML configuration found in archive")
61
+ xml_file = xml_files[0]
62
+ vm_name = xml_file.stem
63
+
64
+ # Move disks to libvirt images directory
65
+ disks_dir = tmp_path / "disks"
66
+ disk_mapping = {}
67
+ if disks_dir.exists():
68
+ for disk_file in disks_dir.iterdir():
69
+ dest = disk_dir / disk_file.name
70
+ shutil.copy2(disk_file, dest)
71
+ disk_mapping[disk_file.name] = dest
72
+ print(f" 💾 Copied disk: {dest}")
73
+
74
+ # Reconfigure disk paths in XML
75
+ vm_xml = self._reconfigure_paths(xml_file, disk_mapping, new_name)
76
+
77
+ # Define and create VM
78
+ vm = self.conn.defineXML(vm_xml)
79
+ final_name = new_name or vm_name
80
+ print(f" ✅ VM defined: {final_name}")
81
+
82
+ # Import user/app data
83
+ if import_user_data:
84
+ self._import_user_data(tmp_path / "user-data")
85
+ if import_app_data:
86
+ self._import_app_data(tmp_path / "app-data")
87
+
88
+ # Start VM
89
+ vm.create()
90
+ print(f" 🚀 VM started: {final_name}")
91
+
92
+ return final_name
93
+
94
+ def _reconfigure_paths(
95
+ self,
96
+ xml_file: Path,
97
+ disk_mapping: dict,
98
+ new_name: Optional[str] = None,
99
+ ) -> str:
100
+ """Update disk paths and optionally rename VM."""
101
+ tree = ET.parse(xml_file)
102
+ root = tree.getroot()
103
+
104
+ # Update name if requested
105
+ if new_name:
106
+ name_elem = root.find("name")
107
+ if name_elem is not None:
108
+ name_elem.text = new_name
109
+
110
+ # Update disk paths
111
+ for disk in root.findall(".//disk[@type='file']"):
112
+ source = disk.find(".//source")
113
+ if source is not None:
114
+ old_path = source.get("file")
115
+ if old_path:
116
+ disk_name = Path(old_path).name
117
+ if disk_name in disk_mapping:
118
+ source.set("file", str(disk_mapping[disk_name]))
119
+ print(f" 🔄 Remapped: {disk_name} → {disk_mapping[disk_name]}")
120
+
121
+ return ET.tostring(root, encoding="unicode")
122
+
123
+ def _import_user_data(self, user_data_dir: Path) -> None:
124
+ """Restore user data."""
125
+ if user_data_dir.exists():
126
+ for item in user_data_dir.iterdir():
127
+ dest = Path.home() / item.name
128
+ if item.is_dir():
129
+ shutil.copytree(item, dest, dirs_exist_ok=True)
130
+ else:
131
+ shutil.copy2(item, dest)
132
+ print(f" 👤 Restored: {dest}")
133
+
134
+ def _import_app_data(self, app_data_dir: Path) -> None:
135
+ """Restore application data."""
136
+ if app_data_dir.exists():
137
+ for item in app_data_dir.iterdir():
138
+ # Map back to original paths
139
+ dest_map = {
140
+ "projects": Path.home() / "projects",
141
+ ".docker": Path.home() / ".docker",
142
+ "myapp": Path("/opt/myapp"),
143
+ "www": Path("/var/www"),
144
+ "docker": Path("/srv/docker"),
145
+ }
146
+ dest = dest_map.get(item.name, Path.home() / item.name)
147
+ try:
148
+ if item.is_dir():
149
+ shutil.copytree(item, dest, dirs_exist_ok=True)
150
+ else:
151
+ shutil.copy2(item, dest)
152
+ print(f" 📁 Restored: {dest}")
153
+ except PermissionError:
154
+ print(f" ⚠️ Permission denied: {dest}")
155
+
156
+ def close(self) -> None:
157
+ if self._conn is not None:
158
+ self._conn.close()
159
+ self._conn = None
160
+
161
+
162
+ class SecureImporter:
163
+ """AES-256 decrypting VM importer."""
164
+
165
+ KEY_PATH = Path.home() / ".clonebox.key"
166
+
167
+ def __init__(self, conn_uri: str = "qemu:///system"):
168
+ self.importer = VMImporter(conn_uri)
169
+
170
+ @classmethod
171
+ def load_key(cls) -> Optional[bytes]:
172
+ """Load decryption key from file."""
173
+ if cls.KEY_PATH.exists():
174
+ return cls.KEY_PATH.read_bytes()
175
+ return None
176
+
177
+ def import_decrypted(
178
+ self,
179
+ encrypted_path: Path,
180
+ import_user_data: bool = False,
181
+ import_app_data: bool = False,
182
+ new_name: Optional[str] = None,
183
+ ) -> str:
184
+ """Import VM with AES-256 decryption."""
185
+ key = self.load_key()
186
+ if key is None:
187
+ raise FileNotFoundError(
188
+ f"No decryption key found at {self.KEY_PATH}. "
189
+ "Copy the team key to this location."
190
+ )
191
+
192
+ fernet = Fernet(key)
193
+
194
+ # Create temporary decrypted archive
195
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
196
+ tmp_path = Path(tmp.name)
197
+
198
+ try:
199
+ # Decrypt
200
+ encrypted_data = encrypted_path.read_bytes()
201
+ decrypted = fernet.decrypt(encrypted_data)
202
+ tmp_path.write_bytes(decrypted)
203
+
204
+ # Import
205
+ vm_name = self.importer.import_vm(
206
+ archive_path=tmp_path,
207
+ import_user_data=import_user_data,
208
+ import_app_data=import_app_data,
209
+ new_name=new_name,
210
+ )
211
+
212
+ finally:
213
+ # Cleanup
214
+ if tmp_path.exists():
215
+ tmp_path.unlink()
216
+
217
+ return vm_name
218
+
219
+ def close(self) -> None:
220
+ self.importer.close()
clonebox/monitor.py ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Real-time resource monitoring for CloneBox VMs and containers.
4
+ """
5
+
6
+ import json
7
+ import subprocess
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ try:
14
+ import libvirt
15
+ except ImportError:
16
+ libvirt = None
17
+
18
+
19
+ @dataclass
20
+ class VMStats:
21
+ """VM resource statistics."""
22
+
23
+ name: str
24
+ state: str
25
+ cpu_percent: float
26
+ memory_used_mb: int
27
+ memory_total_mb: int
28
+ disk_used_gb: float
29
+ disk_total_gb: float
30
+ network_rx_bytes: int
31
+ network_tx_bytes: int
32
+ uptime_seconds: int
33
+
34
+
35
+ @dataclass
36
+ class ContainerStats:
37
+ """Container resource statistics."""
38
+
39
+ name: str
40
+ state: str
41
+ cpu_percent: float
42
+ memory_used_mb: int
43
+ memory_limit_mb: int
44
+ network_rx_bytes: int
45
+ network_tx_bytes: int
46
+ pids: int
47
+
48
+
49
+ class ResourceMonitor:
50
+ """Monitor VM and container resources in real-time."""
51
+
52
+ def __init__(self, conn_uri: str = "qemu:///session"):
53
+ self.conn_uri = conn_uri
54
+ self._conn = None
55
+ self._prev_cpu: Dict[str, tuple] = {}
56
+
57
+ @property
58
+ def conn(self):
59
+ if self._conn is None:
60
+ if libvirt is None:
61
+ raise RuntimeError("libvirt-python not installed")
62
+ self._conn = libvirt.open(self.conn_uri)
63
+ return self._conn
64
+
65
+ def get_vm_stats(self, vm_name: str) -> Optional[VMStats]:
66
+ """Get resource statistics for a VM."""
67
+ try:
68
+ dom = self.conn.lookupByName(vm_name)
69
+ info = dom.info()
70
+
71
+ state_map = {
72
+ libvirt.VIR_DOMAIN_RUNNING: "running",
73
+ libvirt.VIR_DOMAIN_PAUSED: "paused",
74
+ libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
75
+ libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
76
+ libvirt.VIR_DOMAIN_CRASHED: "crashed",
77
+ }
78
+ state = state_map.get(info[0], "unknown")
79
+
80
+ # Memory
81
+ memory_total_mb = info[1] // 1024
82
+ memory_used_mb = info[2] // 1024 if info[2] > 0 else memory_total_mb
83
+
84
+ # CPU percentage (requires two samples)
85
+ cpu_time = info[4]
86
+ now = time.time()
87
+ cpu_percent = 0.0
88
+
89
+ if vm_name in self._prev_cpu:
90
+ prev_time, prev_cpu = self._prev_cpu[vm_name]
91
+ time_delta = now - prev_time
92
+ if time_delta > 0:
93
+ cpu_delta = cpu_time - prev_cpu
94
+ # CPU time is in nanoseconds
95
+ cpu_percent = (cpu_delta / (time_delta * 1e9)) * 100
96
+ cpu_percent = min(cpu_percent, 100.0 * info[3]) # Cap at vcpus * 100%
97
+
98
+ self._prev_cpu[vm_name] = (now, cpu_time)
99
+
100
+ # Disk stats (from block devices)
101
+ disk_used_gb = 0.0
102
+ disk_total_gb = 0.0
103
+ try:
104
+ xml = dom.XMLDesc()
105
+ import xml.etree.ElementTree as ET
106
+
107
+ root = ET.fromstring(xml)
108
+ for disk in root.findall(".//disk[@type='file']"):
109
+ source = disk.find(".//source")
110
+ if source is not None and source.get("file"):
111
+ disk_path = Path(source.get("file"))
112
+ if disk_path.exists():
113
+ size_bytes = disk_path.stat().st_size
114
+ disk_total_gb += size_bytes / (1024**3)
115
+ # Actual usage requires qemu-img info
116
+ disk_used_gb += size_bytes / (1024**3)
117
+ except Exception:
118
+ pass
119
+
120
+ # Network stats
121
+ network_rx = 0
122
+ network_tx = 0
123
+ try:
124
+ for iface in dom.interfaceAddresses(
125
+ libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT
126
+ ).keys():
127
+ stats = dom.interfaceStats(iface)
128
+ network_rx += stats[0]
129
+ network_tx += stats[4]
130
+ except Exception:
131
+ pass
132
+
133
+ return VMStats(
134
+ name=vm_name,
135
+ state=state,
136
+ cpu_percent=cpu_percent,
137
+ memory_used_mb=memory_used_mb,
138
+ memory_total_mb=memory_total_mb,
139
+ disk_used_gb=disk_used_gb,
140
+ disk_total_gb=disk_total_gb,
141
+ network_rx_bytes=network_rx,
142
+ network_tx_bytes=network_tx,
143
+ uptime_seconds=0, # Would need guest agent for accurate uptime
144
+ )
145
+
146
+ except Exception:
147
+ return None
148
+
149
+ def get_all_vm_stats(self) -> List[VMStats]:
150
+ """Get stats for all VMs."""
151
+ stats = []
152
+ try:
153
+ for dom in self.conn.listAllDomains():
154
+ vm_stats = self.get_vm_stats(dom.name())
155
+ if vm_stats:
156
+ stats.append(vm_stats)
157
+ except Exception:
158
+ pass
159
+ return stats
160
+
161
+ def get_container_stats(self, engine: str = "auto") -> List[ContainerStats]:
162
+ """Get resource statistics for containers."""
163
+ if engine == "auto":
164
+ engine = "podman" if self._check_engine("podman") else "docker"
165
+
166
+ try:
167
+ result = subprocess.run(
168
+ [engine, "stats", "--no-stream", "--format", "json"],
169
+ capture_output=True,
170
+ text=True,
171
+ timeout=10,
172
+ )
173
+ if result.returncode != 0:
174
+ return []
175
+
176
+ containers = json.loads(result.stdout) if result.stdout.strip() else []
177
+ stats = []
178
+
179
+ for c in containers:
180
+ # Parse CPU percentage
181
+ cpu_str = c.get("CPUPerc", "0%").replace("%", "")
182
+ try:
183
+ cpu_percent = float(cpu_str)
184
+ except ValueError:
185
+ cpu_percent = 0.0
186
+
187
+ # Parse memory
188
+ mem_usage = c.get("MemUsage", "0MiB / 0MiB")
189
+ mem_parts = mem_usage.split("/")
190
+ mem_used = self._parse_memory(mem_parts[0].strip()) if len(mem_parts) > 0 else 0
191
+ mem_limit = self._parse_memory(mem_parts[1].strip()) if len(mem_parts) > 1 else 0
192
+
193
+ # Parse network
194
+ net_io = c.get("NetIO", "0B / 0B")
195
+ net_parts = net_io.split("/")
196
+ net_rx = self._parse_bytes(net_parts[0].strip()) if len(net_parts) > 0 else 0
197
+ net_tx = self._parse_bytes(net_parts[1].strip()) if len(net_parts) > 1 else 0
198
+
199
+ stats.append(
200
+ ContainerStats(
201
+ name=c.get("Name", c.get("Names", "unknown")),
202
+ state="running",
203
+ cpu_percent=cpu_percent,
204
+ memory_used_mb=mem_used,
205
+ memory_limit_mb=mem_limit,
206
+ network_rx_bytes=net_rx,
207
+ network_tx_bytes=net_tx,
208
+ pids=int(c.get("PIDs", 0)),
209
+ )
210
+ )
211
+
212
+ return stats
213
+
214
+ except Exception:
215
+ return []
216
+
217
+ def _check_engine(self, engine: str) -> bool:
218
+ """Check if container engine is available."""
219
+ try:
220
+ result = subprocess.run(
221
+ [engine, "--version"], capture_output=True, timeout=5
222
+ )
223
+ return result.returncode == 0
224
+ except Exception:
225
+ return False
226
+
227
+ def _parse_memory(self, mem_str: str) -> int:
228
+ """Parse memory string like '100MiB' to MB."""
229
+ mem_str = mem_str.upper()
230
+ try:
231
+ if "GIB" in mem_str or "GB" in mem_str:
232
+ return int(float(mem_str.replace("GIB", "").replace("GB", "").strip()) * 1024)
233
+ elif "MIB" in mem_str or "MB" in mem_str:
234
+ return int(float(mem_str.replace("MIB", "").replace("MB", "").strip()))
235
+ elif "KIB" in mem_str or "KB" in mem_str:
236
+ return int(float(mem_str.replace("KIB", "").replace("KB", "").strip()) / 1024)
237
+ else:
238
+ return int(float(mem_str.replace("B", "").strip()) / (1024 * 1024))
239
+ except ValueError:
240
+ return 0
241
+
242
+ def _parse_bytes(self, bytes_str: str) -> int:
243
+ """Parse byte string like '1.5GB' to bytes."""
244
+ bytes_str = bytes_str.upper()
245
+ try:
246
+ if "GB" in bytes_str:
247
+ return int(float(bytes_str.replace("GB", "").strip()) * 1024**3)
248
+ elif "MB" in bytes_str:
249
+ return int(float(bytes_str.replace("MB", "").strip()) * 1024**2)
250
+ elif "KB" in bytes_str:
251
+ return int(float(bytes_str.replace("KB", "").strip()) * 1024)
252
+ else:
253
+ return int(float(bytes_str.replace("B", "").strip()))
254
+ except ValueError:
255
+ return 0
256
+
257
+ def close(self) -> None:
258
+ if self._conn is not None:
259
+ self._conn.close()
260
+ self._conn = None
261
+
262
+
263
+ def format_bytes(num_bytes: int) -> str:
264
+ """Format bytes to human-readable string."""
265
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
266
+ if abs(num_bytes) < 1024.0:
267
+ return f"{num_bytes:.1f}{unit}"
268
+ num_bytes /= 1024.0
269
+ return f"{num_bytes:.1f}PB"