clonebox 1.1.4__tar.gz → 1.1.6__tar.gz

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.

Potentially problematic release.


This version of clonebox might be problematic. Click here for more details.

Files changed (59) hide show
  1. {clonebox-1.1.4/src/clonebox.egg-info → clonebox-1.1.6}/PKG-INFO +52 -2
  2. {clonebox-1.1.4 → clonebox-1.1.6}/README.md +49 -1
  3. {clonebox-1.1.4 → clonebox-1.1.6}/pyproject.toml +3 -1
  4. clonebox-1.1.6/src/clonebox/backends/libvirt_backend.py +217 -0
  5. clonebox-1.1.6/src/clonebox/backends/qemu_disk.py +52 -0
  6. clonebox-1.1.6/src/clonebox/backends/subprocess_runner.py +56 -0
  7. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/cli.py +227 -45
  8. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/cloner.py +327 -189
  9. clonebox-1.1.6/src/clonebox/di.py +176 -0
  10. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/health/__init__.py +2 -1
  11. clonebox-1.1.6/src/clonebox/health/manager.py +328 -0
  12. clonebox-1.1.6/src/clonebox/health/probes.py +337 -0
  13. clonebox-1.1.6/src/clonebox/interfaces/disk.py +40 -0
  14. clonebox-1.1.6/src/clonebox/interfaces/hypervisor.py +89 -0
  15. clonebox-1.1.6/src/clonebox/interfaces/network.py +33 -0
  16. clonebox-1.1.6/src/clonebox/interfaces/process.py +46 -0
  17. clonebox-1.1.6/src/clonebox/logging.py +125 -0
  18. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/models.py +2 -2
  19. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/monitor.py +1 -3
  20. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/p2p.py +4 -2
  21. clonebox-1.1.6/src/clonebox/resource_monitor.py +162 -0
  22. clonebox-1.1.6/src/clonebox/resources.py +222 -0
  23. clonebox-1.1.6/src/clonebox/rollback.py +172 -0
  24. clonebox-1.1.6/src/clonebox/secrets.py +331 -0
  25. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/snapshots/manager.py +3 -9
  26. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/snapshots/models.py +2 -6
  27. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/validator.py +34 -0
  28. {clonebox-1.1.4 → clonebox-1.1.6/src/clonebox.egg-info}/PKG-INFO +52 -2
  29. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox.egg-info/SOURCES.txt +15 -0
  30. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox.egg-info/requires.txt +2 -0
  31. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_cloner.py +24 -7
  32. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_cloner_simple.py +7 -4
  33. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_coverage_boost_final.py +61 -21
  34. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_models.py +3 -3
  35. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_network.py +17 -2
  36. {clonebox-1.1.4 → clonebox-1.1.6}/LICENSE +0 -0
  37. {clonebox-1.1.4 → clonebox-1.1.6}/setup.cfg +0 -0
  38. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/__init__.py +0 -0
  39. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/__main__.py +0 -0
  40. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/container.py +0 -0
  41. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/dashboard.py +0 -0
  42. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/detector.py +0 -0
  43. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/exporter.py +0 -0
  44. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/health/models.py +0 -0
  45. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/importer.py +0 -0
  46. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/profiles.py +0 -0
  47. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/snapshots/__init__.py +0 -0
  48. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/templates/profiles/ml-dev.yaml +0 -0
  49. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox/templates/profiles/web-stack.yaml +0 -0
  50. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox.egg-info/dependency_links.txt +0 -0
  51. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox.egg-info/entry_points.txt +0 -0
  52. {clonebox-1.1.4 → clonebox-1.1.6}/src/clonebox.egg-info/top_level.txt +0 -0
  53. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_cli.py +0 -0
  54. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_container.py +0 -0
  55. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_coverage_additional.py +0 -0
  56. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_dashboard_coverage.py +0 -0
  57. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_detector.py +0 -0
  58. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_profiles.py +0 -0
  59. {clonebox-1.1.4 → clonebox-1.1.6}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clonebox
3
- Version: 1.1.4
3
+ Version: 1.1.6
4
4
  Summary: Clone your workstation environment to an isolated VM with selective apps, paths and services
5
5
  Author: CloneBox Team
6
6
  License: Apache-2.0
@@ -32,6 +32,8 @@ Requires-Dist: psutil>=5.9.0
32
32
  Requires-Dist: pyyaml>=6.0
33
33
  Requires-Dist: pydantic>=2.0.0
34
34
  Requires-Dist: python-dotenv>=1.0.0
35
+ Requires-Dist: cryptography>=42.0.0
36
+ Requires-Dist: structlog>=24.0.0
35
37
  Provides-Extra: dev
36
38
  Requires-Dist: pytest>=7.0.0; extra == "dev"
37
39
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -114,6 +116,8 @@ CloneBox excels in scenarios where developers need:
114
116
  | 🎛️ Profiles System (`ml-dev`, `web-stack`) | ✅ Stable |
115
117
  | 🔍 Auto-detection (services, apps, paths) | ✅ Stable |
116
118
  | 🔒 P2P Secure Transfer (AES-256) | ✅ **NEW** |
119
+ | 📸 Snapshot Management | ✅ **NEW** |
120
+ | 🏥 Health Check System | ✅ **NEW** |
117
121
  | 🧪 95%+ Test Coverage | ✅ |
118
122
 
119
123
  ### P2P Secure VM Sharing
@@ -141,9 +145,55 @@ clonebox sync-key user@hostB # Sync encryption key
141
145
  clonebox list-remote user@hostB # List remote VMs
142
146
  ```
143
147
 
148
+ ### Snapshot Management
149
+
150
+ Save and restore VM states:
151
+
152
+ ```bash
153
+ # Create snapshot before risky operation
154
+ clonebox snapshot create my-vm --name "before-upgrade" --user
155
+
156
+ # List all snapshots
157
+ clonebox snapshot list my-vm --user
158
+
159
+ # Restore to previous state
160
+ clonebox snapshot restore my-vm --name "before-upgrade" --user
161
+
162
+ # Delete old snapshot
163
+ clonebox snapshot delete my-vm --name "before-upgrade" --user
164
+ ```
165
+
166
+ ### Health Checks
167
+
168
+ Configure health probes in `.clonebox.yaml`:
169
+
170
+ ```yaml
171
+ health_checks:
172
+ - name: nginx
173
+ type: http
174
+ url: http://localhost:80/health
175
+ expected_status: 200
176
+
177
+ - name: postgres
178
+ type: tcp
179
+ host: localhost
180
+ port: 5432
181
+
182
+ - name: redis
183
+ type: command
184
+ exec: "redis-cli ping"
185
+ expected_output: "PONG"
186
+ ```
187
+
188
+ Run health checks:
189
+
190
+ ```bash
191
+ clonebox health my-vm --user
192
+ ```
193
+
144
194
  ### Roadmap
145
195
 
146
- - **v1.2.0**: `clonebox exec` command, VM snapshots, snapshot restore
196
+ - **v1.2.0**: Resource limits, progress bars, secrets isolation
147
197
  - **v1.3.0**: Multi-VM orchestration, cluster mode
148
198
  - **v2.0.0**: Cloud provider support (AWS, GCP, Azure), Windows WSL2 support
149
199
 
@@ -63,6 +63,8 @@ CloneBox excels in scenarios where developers need:
63
63
  | 🎛️ Profiles System (`ml-dev`, `web-stack`) | ✅ Stable |
64
64
  | 🔍 Auto-detection (services, apps, paths) | ✅ Stable |
65
65
  | 🔒 P2P Secure Transfer (AES-256) | ✅ **NEW** |
66
+ | 📸 Snapshot Management | ✅ **NEW** |
67
+ | 🏥 Health Check System | ✅ **NEW** |
66
68
  | 🧪 95%+ Test Coverage | ✅ |
67
69
 
68
70
  ### P2P Secure VM Sharing
@@ -90,9 +92,55 @@ clonebox sync-key user@hostB # Sync encryption key
90
92
  clonebox list-remote user@hostB # List remote VMs
91
93
  ```
92
94
 
95
+ ### Snapshot Management
96
+
97
+ Save and restore VM states:
98
+
99
+ ```bash
100
+ # Create snapshot before risky operation
101
+ clonebox snapshot create my-vm --name "before-upgrade" --user
102
+
103
+ # List all snapshots
104
+ clonebox snapshot list my-vm --user
105
+
106
+ # Restore to previous state
107
+ clonebox snapshot restore my-vm --name "before-upgrade" --user
108
+
109
+ # Delete old snapshot
110
+ clonebox snapshot delete my-vm --name "before-upgrade" --user
111
+ ```
112
+
113
+ ### Health Checks
114
+
115
+ Configure health probes in `.clonebox.yaml`:
116
+
117
+ ```yaml
118
+ health_checks:
119
+ - name: nginx
120
+ type: http
121
+ url: http://localhost:80/health
122
+ expected_status: 200
123
+
124
+ - name: postgres
125
+ type: tcp
126
+ host: localhost
127
+ port: 5432
128
+
129
+ - name: redis
130
+ type: command
131
+ exec: "redis-cli ping"
132
+ expected_output: "PONG"
133
+ ```
134
+
135
+ Run health checks:
136
+
137
+ ```bash
138
+ clonebox health my-vm --user
139
+ ```
140
+
93
141
  ### Roadmap
94
142
 
95
- - **v1.2.0**: `clonebox exec` command, VM snapshots, snapshot restore
143
+ - **v1.2.0**: Resource limits, progress bars, secrets isolation
96
144
  - **v1.3.0**: Multi-VM orchestration, cluster mode
97
145
  - **v2.0.0**: Cloud provider support (AWS, GCP, Azure), Windows WSL2 support
98
146
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "clonebox"
7
- version = "1.1.4"
7
+ version = "1.1.6"
8
8
  description = "Clone your workstation environment to an isolated VM with selective apps, paths and services"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -38,6 +38,8 @@ dependencies = [
38
38
  "pyyaml>=6.0",
39
39
  "pydantic>=2.0.0",
40
40
  "python-dotenv>=1.0.0",
41
+ "cryptography>=42.0.0",
42
+ "structlog>=24.0.0",
41
43
  ]
42
44
 
43
45
  [project.optional-dependencies]
@@ -0,0 +1,217 @@
1
+ """libvirt hypervisor backend implementation."""
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ try:
9
+ import libvirt
10
+ except ImportError:
11
+ libvirt = None
12
+
13
+ from ..interfaces.hypervisor import HypervisorBackend, VMInfo
14
+
15
+
16
+ class LibvirtBackend(HypervisorBackend):
17
+ """libvirt hypervisor backend."""
18
+
19
+ name = "libvirt"
20
+
21
+ def __init__(
22
+ self,
23
+ uri: Optional[str] = None,
24
+ user_session: bool = False,
25
+ ):
26
+ self.uri = uri or ("qemu:///session" if user_session else "qemu:///system")
27
+ self._conn: Optional[libvirt.virConnect] = None
28
+
29
+ def connect(self) -> None:
30
+ """Establish connection to libvirt."""
31
+ if self._conn is not None:
32
+ try:
33
+ if self._conn.isAlive():
34
+ return
35
+ except libvirt.libvirtError:
36
+ pass
37
+
38
+ try:
39
+ self._conn = libvirt.open(self.uri)
40
+ except libvirt.libvirtError as e:
41
+ raise ConnectionError(f"Failed to connect to libvirt at {self.uri}: {e}")
42
+
43
+ def disconnect(self) -> None:
44
+ """Close connection."""
45
+ if self._conn:
46
+ try:
47
+ self._conn.close()
48
+ except libvirt.libvirtError:
49
+ pass
50
+ self._conn = None
51
+
52
+ @property
53
+ def conn(self) -> libvirt.virConnect:
54
+ """Get active libvirt connection."""
55
+ if self._conn is None:
56
+ self.connect()
57
+ return self._conn
58
+
59
+ def define_vm(self, config: Any) -> str:
60
+ """Define a new VM. Returns VM name."""
61
+ # This normally takes XML, but for DI we might want to pass config
62
+ # and let the backend handle XML generation if it's backend-specific.
63
+ # For now, we assume the caller provides the XML via config.xml or similar
64
+ # if they want to use this generic interface, or we refactor cloner to use backend.
65
+ if hasattr(config, "xml"):
66
+ domain = self.conn.defineXML(config.xml)
67
+ return domain.name()
68
+ raise ValueError("Config must provide XML for libvirt backend")
69
+
70
+ def undefine_vm(self, name: str) -> None:
71
+ """Remove VM definition."""
72
+ try:
73
+ domain = self.conn.lookupByName(name)
74
+ if domain.isActive():
75
+ domain.destroy()
76
+ domain.undefine()
77
+ except libvirt.libvirtError as e:
78
+ if "not found" not in str(e).lower():
79
+ raise
80
+
81
+ def start_vm(self, name: str) -> None:
82
+ """Start a VM."""
83
+ domain = self.conn.lookupByName(name)
84
+ if not domain.isActive():
85
+ domain.create()
86
+
87
+ def stop_vm(self, name: str, force: bool = False) -> None:
88
+ """Stop a VM."""
89
+ domain = self.conn.lookupByName(name)
90
+ if domain.isActive():
91
+ if force:
92
+ domain.destroy()
93
+ else:
94
+ domain.shutdown()
95
+
96
+ def get_vm_info(self, name: str) -> Optional[VMInfo]:
97
+ """Get VM information."""
98
+ try:
99
+ domain = self.conn.lookupByName(name)
100
+ except libvirt.libvirtError:
101
+ return None
102
+
103
+ info = domain.info()
104
+ state_map = {
105
+ libvirt.VIR_DOMAIN_RUNNING: "running",
106
+ libvirt.VIR_DOMAIN_BLOCKED: "blocked",
107
+ libvirt.VIR_DOMAIN_PAUSED: "paused",
108
+ libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
109
+ libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
110
+ libvirt.VIR_DOMAIN_CRASHED: "crashed",
111
+ libvirt.VIR_DOMAIN_PMSUSPENDED: "pmsuspended",
112
+ }
113
+
114
+ return VMInfo(
115
+ name=domain.name(),
116
+ state=state_map.get(info[0], "unknown"),
117
+ uuid=domain.UUIDString(),
118
+ memory_mb=info[1] // 1024,
119
+ vcpus=info[3],
120
+ ip_addresses=self._get_ip_addresses(domain),
121
+ )
122
+
123
+ def list_vms(self) -> List[VMInfo]:
124
+ """List all VMs."""
125
+ vms = []
126
+ try:
127
+ # List all domains (running and defined)
128
+ for domain in self.conn.listAllDomains():
129
+ info = self.get_vm_info(domain.name())
130
+ if info:
131
+ vms.append(info)
132
+ except libvirt.libvirtError:
133
+ pass
134
+ return vms
135
+
136
+ def vm_exists(self, name: str) -> bool:
137
+ """Check if VM exists."""
138
+ try:
139
+ self.conn.lookupByName(name)
140
+ return True
141
+ except libvirt.libvirtError:
142
+ return False
143
+
144
+ def is_running(self, name: str) -> bool:
145
+ """Check if VM is running."""
146
+ try:
147
+ domain = self.conn.lookupByName(name)
148
+ return domain.isActive() == 1
149
+ except libvirt.libvirtError:
150
+ return False
151
+
152
+ def execute_command(
153
+ self,
154
+ name: str,
155
+ command: str,
156
+ timeout: int = 30,
157
+ ) -> Optional[str]:
158
+ """Execute command in VM via QEMU Guest Agent."""
159
+ domain = self.conn.lookupByName(name)
160
+
161
+ # Build QGA guest-exec command
162
+ qga_cmd = {
163
+ "execute": "guest-exec",
164
+ "arguments": {
165
+ "path": "/bin/bash",
166
+ "arg": ["-c", command],
167
+ "capture-output": True,
168
+ },
169
+ }
170
+
171
+ try:
172
+ result_json = domain.qemuAgentCommand(json.dumps(qga_cmd), timeout)
173
+ result_data = json.loads(result_json)
174
+ pid = result_data["return"]["pid"]
175
+
176
+ # Poll for completion
177
+ status_cmd = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
178
+ start_time = time.time()
179
+ while time.time() - start_time < timeout:
180
+ status_json = domain.qemuAgentCommand(json.dumps(status_cmd), timeout)
181
+ status_data = json.loads(status_json)
182
+
183
+ if status_data["return"]["exited"]:
184
+ if "out-data" in status_data["return"]:
185
+ return base64.b64decode(status_data["return"]["out-data"]).decode(
186
+ "utf-8", errors="replace"
187
+ )
188
+ return ""
189
+
190
+ time.sleep(0.5)
191
+
192
+ return None # Timeout
193
+
194
+ except Exception:
195
+ return None
196
+
197
+ def _get_ip_addresses(self, domain) -> List[str]:
198
+ """Get IP addresses from domain via guest agent or lease."""
199
+ ips = []
200
+ try:
201
+ # Try guest agent first (more accurate)
202
+ ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT)
203
+ for iface_data in ifaces.values():
204
+ for addr in iface_data.get("addrs", []):
205
+ if addr["type"] == 0: # IPv4
206
+ ips.append(addr["addr"])
207
+ except libvirt.libvirtError:
208
+ # Fallback to network leases
209
+ try:
210
+ ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE)
211
+ for iface_data in ifaces.values():
212
+ for addr in iface_data.get("addrs", []):
213
+ if addr["type"] == 0:
214
+ ips.append(addr["addr"])
215
+ except libvirt.libvirtError:
216
+ pass
217
+ return ips
@@ -0,0 +1,52 @@
1
+ """QEMU disk manager implementation."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ..interfaces.disk import DiskManager
8
+
9
+
10
+ class QemuDiskManager(DiskManager):
11
+ """Manage VM disks using qemu-img."""
12
+
13
+ def create_disk(
14
+ self,
15
+ path: Path,
16
+ size_gb: int,
17
+ format: str = "qcow2",
18
+ backing_file: Optional[Path] = None,
19
+ ) -> Path:
20
+ """Create a disk image."""
21
+ cmd = ["qemu-img", "create", "-f", format]
22
+
23
+ if backing_file:
24
+ cmd.extend(["-b", str(backing_file), "-F", format])
25
+
26
+ cmd.extend([str(path), f"{size_gb}G"])
27
+
28
+ subprocess.run(cmd, check=True, capture_output=True)
29
+ return path
30
+
31
+ def resize_disk(self, path: Path, new_size_gb: int) -> None:
32
+ """Resize a disk image."""
33
+ cmd = ["qemu-img", "resize", str(path), f"{new_size_gb}G"]
34
+ subprocess.run(cmd, check=True, capture_output=True)
35
+
36
+ def get_disk_info(self, path: Path) -> Dict[str, Any]:
37
+ """Get disk image information."""
38
+ import json
39
+ cmd = ["qemu-img", "info", "--output=json", str(path)]
40
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
41
+ return json.loads(result.stdout)
42
+
43
+ def create_snapshot(self, path: Path, snapshot_name: str) -> Path:
44
+ """Create internal disk snapshot."""
45
+ cmd = ["qemu-img", "snapshot", "-c", snapshot_name, str(path)]
46
+ subprocess.run(cmd, check=True, capture_output=True)
47
+ return path
48
+
49
+ def delete_disk(self, path: Path) -> None:
50
+ """Delete disk image."""
51
+ if path.exists():
52
+ path.unlink()
@@ -0,0 +1,56 @@
1
+ """Subprocess process runner implementation."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+
7
+ from ..interfaces.process import ProcessResult, ProcessRunner
8
+
9
+
10
+ class SubprocessRunner(ProcessRunner):
11
+ """Run processes using the subprocess module."""
12
+
13
+ def run(
14
+ self,
15
+ command: List[str],
16
+ capture_output: bool = True,
17
+ timeout: Optional[int] = None,
18
+ check: bool = True,
19
+ cwd: Optional[Path] = None,
20
+ env: Optional[Dict[str, str]] = None,
21
+ ) -> ProcessResult:
22
+ """Run a command."""
23
+ result = subprocess.run(
24
+ command,
25
+ capture_output=capture_output,
26
+ timeout=timeout,
27
+ check=check,
28
+ cwd=str(cwd) if cwd else None,
29
+ env=env,
30
+ text=True,
31
+ )
32
+ return ProcessResult(
33
+ returncode=result.returncode,
34
+ stdout=result.stdout,
35
+ stderr=result.stderr,
36
+ )
37
+
38
+ def run_shell(
39
+ self,
40
+ command: str,
41
+ capture_output: bool = True,
42
+ timeout: Optional[int] = None,
43
+ ) -> ProcessResult:
44
+ """Run a shell command."""
45
+ result = subprocess.run(
46
+ command,
47
+ shell=True,
48
+ capture_output=capture_output,
49
+ timeout=timeout,
50
+ text=True,
51
+ )
52
+ return ProcessResult(
53
+ returncode=result.returncode,
54
+ stdout=result.stdout,
55
+ stderr=result.stderr,
56
+ )