clonebox 0.1.25__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.
clonebox/models.py ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pydantic models for CloneBox configuration validation.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Literal, Optional
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel, Field, field_validator, model_validator
11
+
12
+
13
+ class VMSettings(BaseModel):
14
+ """VM-specific settings."""
15
+
16
+ name: str = Field(default="clonebox-vm", description="VM name")
17
+ ram_mb: int = Field(default=4096, ge=512, le=131072, description="RAM in MB")
18
+ vcpus: int = Field(default=4, ge=1, le=128, description="Number of vCPUs")
19
+ disk_size_gb: int = Field(default=20, ge=1, le=2048, description="Disk size in GB")
20
+ gui: bool = Field(default=True, description="Enable SPICE graphics")
21
+ base_image: Optional[str] = Field(default=None, description="Path to base qcow2 image")
22
+ network_mode: str = Field(default="auto", description="Network mode: auto|default|user")
23
+ username: str = Field(default="ubuntu", description="VM default username")
24
+ password: str = Field(default="ubuntu", description="VM default password")
25
+
26
+ @field_validator("name")
27
+ @classmethod
28
+ def name_must_be_valid(cls, v: str) -> str:
29
+ if not v or not v.strip():
30
+ raise ValueError("VM name cannot be empty")
31
+ if len(v) > 64:
32
+ raise ValueError("VM name must be <= 64 characters")
33
+ return v.strip()
34
+
35
+ @field_validator("network_mode")
36
+ @classmethod
37
+ def network_mode_must_be_valid(cls, v: str) -> str:
38
+ valid_modes = {"auto", "default", "user"}
39
+ if v not in valid_modes:
40
+ raise ValueError(f"network_mode must be one of: {valid_modes}")
41
+ return v
42
+
43
+
44
+ class CloneBoxConfig(BaseModel):
45
+ """Complete CloneBox configuration with validation."""
46
+
47
+ version: str = Field(default="1", description="Config version")
48
+ generated: Optional[str] = Field(default=None, description="Generation timestamp")
49
+ vm: VMSettings = Field(default_factory=VMSettings, description="VM settings")
50
+ paths: Dict[str, str] = Field(default_factory=dict, description="Host:Guest path mappings")
51
+ app_data_paths: Dict[str, str] = Field(
52
+ default_factory=dict, description="Application data paths"
53
+ )
54
+ packages: List[str] = Field(default_factory=list, description="APT packages to install")
55
+ snap_packages: List[str] = Field(default_factory=list, description="Snap packages to install")
56
+ services: List[str] = Field(default_factory=list, description="Services to enable")
57
+ post_commands: List[str] = Field(default_factory=list, description="Post-setup commands")
58
+ detected: Optional[Dict[str, Any]] = Field(
59
+ default=None, description="Auto-detected system info"
60
+ )
61
+
62
+ @field_validator("paths", "app_data_paths")
63
+ @classmethod
64
+ def paths_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
65
+ for host_path, guest_path in v.items():
66
+ if not host_path.startswith("/"):
67
+ raise ValueError(f"Host path must be absolute: {host_path}")
68
+ if not guest_path.startswith("/"):
69
+ raise ValueError(f"Guest path must be absolute: {guest_path}")
70
+ return v
71
+
72
+ @model_validator(mode="before")
73
+ @classmethod
74
+ def handle_nested_vm(cls, data: Any) -> Any:
75
+ """Handle both dict and nested vm structures."""
76
+ if isinstance(data, dict):
77
+ if "vm" in data and isinstance(data["vm"], dict):
78
+ return data
79
+ vm_fields = {"name", "ram_mb", "vcpus", "disk_size_gb", "gui", "base_image",
80
+ "network_mode", "username", "password"}
81
+ vm_data = {k: v for k, v in data.items() if k in vm_fields}
82
+ if vm_data:
83
+ data = {k: v for k, v in data.items() if k not in vm_fields}
84
+ data["vm"] = vm_data
85
+ return data
86
+
87
+ def save(self, path: Path) -> None:
88
+ """Save configuration to YAML file."""
89
+ import yaml
90
+
91
+ config_dict = self.model_dump(exclude_none=True)
92
+ path.write_text(yaml.dump(config_dict, default_flow_style=False, sort_keys=False))
93
+
94
+ @classmethod
95
+ def load(cls, path: Path) -> "CloneBoxConfig":
96
+ """Load configuration from YAML file."""
97
+ import yaml
98
+
99
+ if path.is_dir():
100
+ path = path / ".clonebox.yaml"
101
+ if not path.exists():
102
+ raise FileNotFoundError(f"Config file not found: {path}")
103
+ data = yaml.safe_load(path.read_text())
104
+ return cls.model_validate(data)
105
+
106
+ def to_vm_config(self) -> "VMConfigDataclass":
107
+ """Convert to legacy VMConfig dataclass for compatibility."""
108
+ from clonebox.cloner import VMConfig as VMConfigDataclass
109
+
110
+ # Merge paths and app_data_paths
111
+ all_paths = dict(self.paths)
112
+ all_paths.update(self.app_data_paths)
113
+
114
+ return VMConfigDataclass(
115
+ name=self.vm.name,
116
+ ram_mb=self.vm.ram_mb,
117
+ vcpus=self.vm.vcpus,
118
+ disk_size_gb=self.vm.disk_size_gb,
119
+ gui=self.vm.gui,
120
+ base_image=self.vm.base_image,
121
+ paths=all_paths,
122
+ packages=self.packages,
123
+ snap_packages=self.snap_packages,
124
+ services=self.services,
125
+ post_commands=self.post_commands,
126
+ network_mode=self.vm.network_mode,
127
+ username=self.vm.username,
128
+ password=self.vm.password,
129
+ )
130
+
131
+
132
+ class ContainerConfig(BaseModel):
133
+ name: str = Field(default_factory=lambda: f"clonebox-{uuid4().hex[:8]}")
134
+ engine: Literal["auto", "podman", "docker"] = "auto"
135
+ image: str = "ubuntu:22.04"
136
+ workspace: Path = Path(".")
137
+ extra_mounts: Dict[str, str] = Field(default_factory=dict)
138
+ env_from_dotenv: bool = True
139
+ packages: List[str] = Field(default_factory=list)
140
+ ports: List[str] = Field(default_factory=list)
141
+
142
+ @field_validator("name")
143
+ @classmethod
144
+ def name_must_be_valid(cls, v: str) -> str:
145
+ if not v or not v.strip():
146
+ raise ValueError("Container name cannot be empty")
147
+ if len(v) > 64:
148
+ raise ValueError("Container name must be <= 64 characters")
149
+ return v.strip()
150
+
151
+ @field_validator("extra_mounts")
152
+ @classmethod
153
+ def extra_mounts_must_be_absolute(cls, v: Dict[str, str]) -> Dict[str, str]:
154
+ for host_path, container_path in v.items():
155
+ if not str(host_path).startswith("/"):
156
+ raise ValueError(f"Host path must be absolute: {host_path}")
157
+ if not str(container_path).startswith("/"):
158
+ raise ValueError(f"Container path must be absolute: {container_path}")
159
+ return v
160
+
161
+ @field_validator("ports")
162
+ @classmethod
163
+ def ports_must_be_valid(cls, v: List[str]) -> List[str]:
164
+ for p in v:
165
+ if not isinstance(p, str) or not p.strip():
166
+ raise ValueError("Port mapping cannot be empty")
167
+ if ":" in p:
168
+ host, container = p.split(":", 1)
169
+ if not host.isdigit() or not container.isdigit():
170
+ raise ValueError(f"Invalid port mapping: {p}")
171
+ else:
172
+ if not p.isdigit():
173
+ raise ValueError(f"Invalid port value: {p}")
174
+ return v
175
+
176
+ @property
177
+ def mounts(self) -> Dict[str, str]:
178
+ mounts: Dict[str, str] = {
179
+ str(self.workspace.resolve()): "/workspace",
180
+ }
181
+ mounts.update(self.extra_mounts)
182
+ return mounts
183
+
184
+ def to_docker_run_cmd(self) -> List[str]:
185
+ if self.engine == "auto":
186
+ raise ValueError("engine must be resolved before generating run command")
187
+
188
+ cmd: List[str] = [self.engine, "run", "-it", "--rm", "--name", self.name]
189
+
190
+ for src, dst in self.mounts.items():
191
+ cmd.extend(["-v", f"{src}:{dst}"])
192
+
193
+ for p in self.ports:
194
+ cmd.extend(["-p", p])
195
+
196
+ cmd.append(self.image)
197
+ return cmd
198
+
199
+
200
+ # Backwards compatibility alias
201
+ VMConfigModel = CloneBoxConfig
clonebox/profiles.py ADDED
@@ -0,0 +1,66 @@
1
+ import pkgutil
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional
4
+
5
+ import yaml
6
+
7
+
8
+ def load_profile(profile_name: str, search_paths: list[Path]) -> Optional[Dict[str, Any]]:
9
+ """Load profile YAML from ~/.clonebox.d/, .clonebox.d/, templates/profiles/"""
10
+ search_paths = search_paths or []
11
+
12
+ profile_paths = [
13
+ Path.home() / ".clonebox.d" / f"{profile_name}.yaml",
14
+ Path.cwd() / ".clonebox.d" / f"{profile_name}.yaml",
15
+ ]
16
+
17
+ for base in search_paths:
18
+ profile_paths.insert(0, base / "templates" / "profiles" / f"{profile_name}.yaml")
19
+ profile_paths.insert(0, base / f"{profile_name}.yaml")
20
+
21
+ for profile_path in profile_paths:
22
+ if profile_path.exists():
23
+ return yaml.safe_load(profile_path.read_text())
24
+
25
+ data = pkgutil.get_data("clonebox", f"templates/profiles/{profile_name}.yaml")
26
+ if data is not None:
27
+ return yaml.safe_load(data.decode())
28
+
29
+ return None
30
+
31
+
32
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
33
+ merged: Dict[str, Any] = dict(base)
34
+ for key, value in override.items():
35
+ if (
36
+ key in merged
37
+ and isinstance(merged[key], dict)
38
+ and isinstance(value, dict)
39
+ ):
40
+ merged[key] = _deep_merge(merged[key], value)
41
+ else:
42
+ merged[key] = value
43
+ return merged
44
+
45
+
46
+ def merge_with_profile(
47
+ base_config: Dict[str, Any],
48
+ profile_name: Optional[str] = None,
49
+ *,
50
+ profile: Optional[Dict[str, Any]] = None,
51
+ search_paths: Optional[list[Path]] = None,
52
+ ) -> Dict[str, Any]:
53
+ """Merge profile OVER base config (profile wins)."""
54
+ if profile is not None:
55
+ if not isinstance(profile, dict):
56
+ return base_config
57
+ return _deep_merge(base_config, profile)
58
+
59
+ if not profile_name:
60
+ return base_config
61
+
62
+ loaded = load_profile(profile_name, search_paths or [])
63
+ if not loaded or not isinstance(loaded, dict):
64
+ return base_config
65
+
66
+ return _deep_merge(base_config, loaded)
@@ -0,0 +1,6 @@
1
+ container:
2
+ image: python:3.11-slim
3
+ packages: ["pip", "jupyterlab"]
4
+ ports: ["8888:8888"]
5
+ vm:
6
+ packages: ["python3-pip", "jupyterlab"]