vuer-cli 0.0.3__py3-none-any.whl → 0.0.5__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.
vuer_cli/sync.py CHANGED
@@ -9,342 +9,348 @@ from typing import Any, List
9
9
  from .envs_publish import Hub
10
10
  from .envs_pull import pull_from_registry
11
11
  from .utils import (
12
- is_dry_run,
13
- print_error,
14
- parse_env_spec,
12
+ is_dry_run,
13
+ parse_env_spec,
14
+ print_error,
15
15
  )
16
16
 
17
17
 
18
18
  @dataclass
19
19
  class Sync:
20
- """Sync environments listed in environment.json dependencies (like npm install).
21
-
22
- Reads environment.json, validates dependencies with backend, and downloads
23
- all environments and their transitive dependencies to vuer_environments/ directory.
24
- """
25
-
26
- output: str = "vuer_environments" # Destination directory (default: vuer_environments)
27
- timeout: int = 3000 # Request timeout in seconds
28
- concurrent: bool = False # Download concurrently (future enhancement)
29
-
30
- def __call__(self) -> int:
31
- """Execute sync command."""
32
- try:
33
- dry_run = is_dry_run()
34
-
35
- if not dry_run:
36
- if not Hub.url:
37
- raise RuntimeError(
38
- "Missing VUER_HUB_URL. Please set the VUER_HUB_URL environment variable "
39
- "or pass --hub.url on the command line."
40
- )
41
- if not Hub.auth_token:
42
- raise RuntimeError(
43
- "Missing VUER_AUTH_TOKEN. Please set the VUER_AUTH_TOKEN environment "
44
- "variable or pass --hub.auth-token on the command line."
45
- )
46
-
47
- # Step 1: Find and parse environment.json
48
- print(
49
- "[INFO] Looking for environment.json in current directory...")
50
- current_dir = Path.cwd()
51
- env_json_path = current_dir / "environment.json"
52
- if not env_json_path.exists():
53
- raise FileNotFoundError(
54
- f"environment.json not found in {current_dir}. "
55
- "Please run this command in a directory containing environment.json."
56
- )
57
-
58
- # Step 2: Parse dependencies
59
- print(f"[INFO] Reading environment.json from {current_dir}...")
60
- dependencies = parse_dependencies(env_json_path)
61
-
62
- # Step 3: Prepare output directory and lockfile path (environments-lock.yaml is
63
- # located alongside the vuer_environments directory)
64
- output_dir = Path(self.output).expanduser().resolve()
65
- output_dir.mkdir(parents=True, exist_ok=True)
66
- lock_path = output_dir.parent / "environments-lock.yaml"
67
-
68
- # Step 4: Handle empty dependencies case - still need to clean up Module/
69
- if not dependencies:
70
- print(
71
- "[INFO] No environments to sync. environment.json has no dependencies.")
72
- # Still reconcile: remove all environments if lockfile exists
73
- previous_deps = read_environments_lock(lock_path)
74
- desired_deps = [] # Empty list since no dependencies
75
- write_environments_lock(lock_path, desired_deps)
76
- print(f"[INFO] Wrote environments lock to {lock_path}")
77
-
78
- # Remove all environments that are no longer needed
79
- removed = remove_unneeded_env_dirs(output_dir, previous_deps, desired_deps)
80
- if removed:
81
- print(f"[INFO] Removed {removed} unused environment directories from {output_dir}")
82
- else:
83
- print("[INFO] No environments to remove from vuer_environments/")
84
- return 0
85
-
86
- print(f"[INFO] Found {len(dependencies)} dependencies to sync.")
87
-
88
- # Step 5: Validate dependencies with backend
89
- print("[INFO] Validating dependencies with backend...")
90
- resolved_deps = validate_and_get_dependencies(dependencies, dry_run)
91
-
92
- # Step 6: Collect all environments (direct + transitive)
93
- all_environments = collect_all_environments(dependencies, resolved_deps)
94
- print(
95
- f"[INFO] Total environments to download: {len(all_environments)}")
96
-
97
- # Step 7: Download all environments to Module/ directory
98
- # Step 10/11: Reconcile dependencies.toml to match current resolution
99
- # (If user removes deps from environment.json, we also remove them from Module/.)
100
- previous_deps = read_environments_lock(lock_path)
101
- desired_deps = dedupe_keep_order(all_environments)
102
- write_environments_lock(lock_path, desired_deps)
103
- print(f"[INFO] Wrote environments lock to {lock_path}")
104
-
105
- print(f"[INFO] Downloading environments to {output_dir}...")
106
- # Remove environments that are no longer needed.
107
- removed = remove_unneeded_env_dirs(output_dir, previous_deps, desired_deps)
108
- if removed:
109
- print(f"[INFO] Removed {removed} unused environment directories from {output_dir}")
110
-
111
- for env_spec in desired_deps:
112
- # map to nested path: vuer_environments/<name>/<version>
113
- name, version = parse_env_spec(env_spec)
114
- env_path = output_dir / name / version
115
- if env_path.exists():
116
- print(f"[INFO] Skipping already-synced {env_spec}")
117
- continue
118
- print(f"[INFO] Downloading {env_spec}...")
119
- _env_dir = pull_from_registry(
120
- env_flag=env_spec,
121
- output_dir=str(output_dir),
122
- # Use filesystem-safe filename derived from name-version
123
- filename=f"{name}-{version}.tgz",
124
- version=None,
125
- timeout=self.timeout,
126
- skip_progress=False,
127
- )
128
-
129
- # Keep downloaded environment.json files intact per new policy.
130
-
131
- print(
132
- f"[SUCCESS] Synced {len(all_environments)} environments to {output_dir}")
133
- return 0
134
-
135
- except FileNotFoundError as e:
136
- print_error(str(e))
137
- return 1
138
- except ValueError as e:
139
- print_error(str(e))
140
- return 1
141
- except RuntimeError as e:
142
- print_error(str(e))
143
- return 1
144
- except Exception as e:
145
- print_error(f"Unexpected error: {e}")
146
- return 1
20
+ """Sync environments listed in environment.json dependencies (like npm install).
147
21
 
22
+ Reads environment.json, validates dependencies with backend, and downloads
23
+ all environments and their transitive dependencies to vuer_environments/ directory.
24
+ """
148
25
 
149
- # -- Helper functions --
150
-
151
-
152
- def parse_dependencies(env_json_path: Path) -> List[str]:
153
- """Parse environment.json and extract dependencies list.
26
+ output: str = (
27
+ "vuer_environments" # Destination directory (default: vuer_environments)
28
+ )
29
+ timeout: int = 3000 # Request timeout in seconds
30
+ concurrent: bool = False # Download concurrently (future enhancement)
154
31
 
155
- Returns:
156
- List of dependency specs like ["some-dependency/^1.2.3", ...]
157
- """
32
+ def __call__(self) -> int:
33
+ """Execute sync command."""
158
34
  try:
159
- with env_json_path.open("r", encoding="utf-8") as f:
160
- data = json.load(f)
161
- except json.JSONDecodeError as e:
162
- raise ValueError(f"Invalid environment.json: {e}") from e
163
-
164
- deps_dict = data.get("dependencies", {})
165
- if not isinstance(deps_dict, dict):
166
- raise ValueError(
167
- "environment.json 'dependencies' field must be an object")
168
-
169
- dependencies = []
170
- for name, version_spec in deps_dict.items():
171
- if not isinstance(version_spec, str):
172
- version_spec = str(version_spec)
173
- dependencies.append(f"{name}/{version_spec}")
35
+ dry_run = is_dry_run()
36
+
37
+ if not dry_run:
38
+ if not Hub.url:
39
+ raise RuntimeError(
40
+ "Missing VUER_HUB_URL. Please set the VUER_HUB_URL environment variable "
41
+ "or pass --hub.url on the command line."
42
+ )
43
+ # Try to get token from credentials file if not in environment
44
+ auth_token = Hub.get_auth_token()
45
+ if not auth_token:
46
+ raise RuntimeError(
47
+ "Missing VUER_AUTH_TOKEN. Please run 'vuer login' to authenticate, "
48
+ "or set the VUER_AUTH_TOKEN environment variable."
49
+ )
50
+
51
+ # Step 1: Find and parse environment.json
52
+ print("[INFO] Looking for environment.json in current directory...")
53
+ current_dir = Path.cwd()
54
+ env_json_path = current_dir / "environment.json"
55
+ if not env_json_path.exists():
56
+ raise FileNotFoundError(
57
+ f"environment.json not found in {current_dir}. "
58
+ "Please run this command in a directory containing environment.json."
59
+ )
60
+
61
+ # Step 2: Parse dependencies
62
+ print(f"[INFO] Reading environment.json from {current_dir}...")
63
+ dependencies = parse_dependencies(env_json_path)
64
+
65
+ # Step 3: Prepare output directory and lockfile path (environments-lock.yaml is
66
+ # located alongside the vuer_environments directory)
67
+ output_dir = Path(self.output).expanduser().resolve()
68
+ output_dir.mkdir(parents=True, exist_ok=True)
69
+ lock_path = output_dir.parent / "environments-lock.yaml"
70
+
71
+ # Step 4: Handle empty dependencies case - still need to clean up Module/
72
+ if not dependencies:
73
+ print("[INFO] No environments to sync. environment.json has no dependencies.")
74
+ # Still reconcile: remove all environments if lockfile exists
75
+ previous_deps = read_environments_lock(lock_path)
76
+ desired_deps = [] # Empty list since no dependencies
77
+ write_environments_lock(lock_path, desired_deps)
78
+ print(f"[INFO] Wrote environments lock to {lock_path}")
79
+
80
+ # Remove all environments that are no longer needed
81
+ removed = remove_unneeded_env_dirs(output_dir, previous_deps, desired_deps)
82
+ if removed:
83
+ print(
84
+ f"[INFO] Removed {removed} unused environment directories from {output_dir}"
85
+ )
86
+ else:
87
+ print("[INFO] No environments to remove from vuer_environments/")
88
+ return 0
89
+
90
+ print(f"[INFO] Found {len(dependencies)} dependencies to sync.")
91
+
92
+ # Step 5: Validate dependencies with backend
93
+ print("[INFO] Validating dependencies with backend...")
94
+ resolved_deps = validate_and_get_dependencies(dependencies, dry_run)
95
+
96
+ # Step 6: Collect all environments (direct + transitive)
97
+ all_environments = collect_all_environments(dependencies, resolved_deps)
98
+ print(f"[INFO] Total environments to download: {len(all_environments)}")
99
+
100
+ # Step 7: Download all environments to Module/ directory
101
+ # Step 10/11: Reconcile dependencies.toml to match current resolution
102
+ # (If user removes deps from environment.json, we also remove them from Module/.)
103
+ previous_deps = read_environments_lock(lock_path)
104
+ desired_deps = dedupe_keep_order(all_environments)
105
+ write_environments_lock(lock_path, desired_deps)
106
+ print(f"[INFO] Wrote environments lock to {lock_path}")
107
+
108
+ print(f"[INFO] Downloading environments to {output_dir}...")
109
+ # Remove environments that are no longer needed.
110
+ removed = remove_unneeded_env_dirs(output_dir, previous_deps, desired_deps)
111
+ if removed:
112
+ print(
113
+ f"[INFO] Removed {removed} unused environment directories from {output_dir}"
114
+ )
115
+
116
+ for env_spec in desired_deps:
117
+ # map to nested path: vuer_environments/<name>/<version>
118
+ name, version = parse_env_spec(env_spec)
119
+ env_path = output_dir / name / version
120
+ if env_path.exists():
121
+ print(f"[INFO] Skipping already-synced {env_spec}")
122
+ continue
123
+ print(f"[INFO] Downloading {env_spec}...")
124
+ _env_dir = pull_from_registry(
125
+ env_flag=env_spec,
126
+ output_dir=str(output_dir),
127
+ # Use filesystem-safe filename derived from name-version
128
+ filename=f"{name}-{version}.tgz",
129
+ version=None,
130
+ timeout=self.timeout,
131
+ skip_progress=False,
132
+ )
133
+
134
+ # Keep downloaded environment.json files intact per new policy.
135
+
136
+ print(f"[SUCCESS] Synced {len(all_environments)} environments to {output_dir}")
137
+ return 0
138
+
139
+ except FileNotFoundError as e:
140
+ print_error(str(e))
141
+ return 1
142
+ except ValueError as e:
143
+ print_error(str(e))
144
+ return 1
145
+ except RuntimeError as e:
146
+ print_error(str(e))
147
+ return 1
148
+ except Exception as e:
149
+ print_error(f"Unexpected error: {e}")
150
+ return 1
174
151
 
175
- return dependencies
176
152
 
153
+ # -- Helper functions --
177
154
 
178
- def validate_and_get_dependencies(
179
- dependencies: List[str], dry_run: bool
180
- ) -> List[str]:
181
- """Validate dependencies with backend and get transitive dependencies.
182
-
183
- Args:
184
- dependencies: List of dependency specs like ["name@version", ...]
185
- dry_run: Whether to run in dry-run mode
186
-
187
- Returns:
188
- A flat list (not deduplicated) of all resolved environment specs,
189
- as returned by the backend:
190
- {"dependencies": ["some-dependency@1.2.5", "numpy@1.24.3", ...]}
191
-
192
- Raises:
193
- RuntimeError: If some environments don't exist
194
- """
195
- if dry_run:
196
- # Dry-run: return mock data
197
- print("[INFO] (dry-run) Validating dependencies (simulated)...")
198
- resolved: List[str] = []
199
- for dep in dependencies:
200
- # Include the requested deps, plus some mock transitive deps (with possible duplicates)
201
- resolved.append(dep)
202
- if "new-dependency" in dep:
203
- continue
204
- if "another-dependency" in dep:
205
- resolved.extend(["react/^18.2.0", "react-dom/^18.2.0", "react/^18.2.0"])
206
- else:
207
- resolved.extend(["numpy/^1.24.0", "pandas/~2.0.0", "numpy/^1.24.0"])
208
- return resolved
209
-
210
- # Real API call
211
- import requests
212
-
213
- url = f"{Hub.url.rstrip('/')}/environments/dependencies"
214
- headers = {
215
- "Authorization": f"Bearer {Hub.auth_token}"} if Hub.auth_token else {}
216
- headers["Content-Type"] = "application/json"
217
-
218
- payload = {"name_versionId_list": dependencies}
219
155
 
220
- try:
221
- response = requests.post(url, json=payload, headers=headers,
222
- timeout=300)
223
- response.raise_for_status()
224
- except requests.exceptions.HTTPError as e:
225
- resp = getattr(e, "response", None)
226
- status = resp.status_code if resp is not None else "unknown"
227
- detail = _extract_backend_error(resp)
228
- raise RuntimeError(
229
- f"Failed to validate dependencies ({status}): {detail}"
230
- ) from e
231
- except requests.exceptions.RequestException as e:
232
- raise RuntimeError(f"Failed to validate dependencies: {e}") from e
233
-
234
- data = response.json()
235
-
236
- # Check for errors (environments not found)
237
- if "error" in data:
238
- error_msg = data["error"]
239
- raise RuntimeError(f"Dependency validation failed: {error_msg}")
240
-
241
- deps = data.get("dependencies", None)
242
- if deps is None:
243
- raise ValueError("Invalid response format: missing 'dependencies' field")
244
- if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
245
- raise ValueError("Invalid response format: 'dependencies' must be a list of strings")
246
- return deps
156
+ def parse_dependencies(env_json_path: Path) -> List[str]:
157
+ """Parse environment.json and extract dependencies list.
158
+
159
+ Returns:
160
+ List of dependency specs like ["some-dependency/^1.2.3", ...]
161
+ """
162
+ try:
163
+ with env_json_path.open("r", encoding="utf-8") as f:
164
+ data = json.load(f)
165
+ except json.JSONDecodeError as e:
166
+ raise ValueError(f"Invalid environment.json: {e}") from e
167
+
168
+ deps_dict = data.get("dependencies", {})
169
+ if not isinstance(deps_dict, dict):
170
+ raise ValueError("environment.json 'dependencies' field must be an object")
171
+
172
+ dependencies = []
173
+ for name, version_spec in deps_dict.items():
174
+ if not isinstance(version_spec, str):
175
+ version_spec = str(version_spec)
176
+ dependencies.append(f"{name}/{version_spec}")
177
+
178
+ return dependencies
179
+
180
+
181
+ def validate_and_get_dependencies(dependencies: List[str], dry_run: bool) -> List[str]:
182
+ """Validate dependencies with backend and get transitive dependencies.
183
+
184
+ Args:
185
+ dependencies: List of dependency specs like ["name@version", ...]
186
+ dry_run: Whether to run in dry-run mode
187
+
188
+ Returns:
189
+ A flat list (not deduplicated) of all resolved environment specs,
190
+ as returned by the backend:
191
+ {"dependencies": ["some-dependency@1.2.5", "numpy@1.24.3", ...]}
192
+
193
+ Raises:
194
+ RuntimeError: If some environments don't exist
195
+ """
196
+ if dry_run:
197
+ # Dry-run: return mock data
198
+ print("[INFO] (dry-run) Validating dependencies (simulated)...")
199
+ resolved: List[str] = []
200
+ for dep in dependencies:
201
+ # Include the requested deps, plus some mock transitive deps (with possible duplicates)
202
+ resolved.append(dep)
203
+ if "new-dependency" in dep:
204
+ continue
205
+ if "another-dependency" in dep:
206
+ resolved.extend(["react/^18.2.0", "react-dom/^18.2.0", "react/^18.2.0"])
207
+ else:
208
+ resolved.extend(["numpy/^1.24.0", "pandas/~2.0.0", "numpy/^1.24.0"])
209
+ return resolved
210
+
211
+ # Real API call
212
+ import requests
213
+
214
+ auth_token = Hub.get_auth_token()
215
+ url = f"{Hub.url.rstrip('/')}/environments/dependencies"
216
+ headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
217
+ headers["Content-Type"] = "application/json"
218
+
219
+ payload = {"name_versionId_list": dependencies}
220
+
221
+ try:
222
+ response = requests.post(url, json=payload, headers=headers, timeout=300)
223
+ response.raise_for_status()
224
+ except requests.exceptions.HTTPError as e:
225
+ resp = getattr(e, "response", None)
226
+ status = resp.status_code if resp is not None else "unknown"
227
+ detail = _extract_backend_error(resp)
228
+ raise RuntimeError(f"Failed to validate dependencies ({status}): {detail}") from e
229
+ except requests.exceptions.RequestException as e:
230
+ raise RuntimeError(f"Failed to validate dependencies: {e}") from e
231
+
232
+ data = response.json()
233
+
234
+ # Check for errors (environments not found)
235
+ if "error" in data:
236
+ error_msg = data["error"]
237
+ raise RuntimeError(f"Dependency validation failed: {error_msg}")
238
+
239
+ deps = data.get("dependencies", None)
240
+ if deps is None:
241
+ raise ValueError("Invalid response format: missing 'dependencies' field")
242
+ if not isinstance(deps, list) or not all(isinstance(x, str) for x in deps):
243
+ raise ValueError(
244
+ "Invalid response format: 'dependencies' must be a list of strings"
245
+ )
246
+ return deps
247
247
 
248
248
 
249
249
  def _extract_backend_error(response: Any) -> str:
250
- """Extract a concise error message from an HTTP response."""
251
- if response is None:
252
- return "No response body"
253
- try:
254
- payload = response.json()
255
- if isinstance(payload, dict):
256
- msg = payload.get("error") or payload.get("message")
257
- if msg:
258
- return str(msg)
259
- return json.dumps(payload, ensure_ascii=False)
260
- except Exception:
261
- text = getattr(response, "text", "") or ""
262
- text = text.strip()
263
- return text if text else "Empty response body"
250
+ """Extract a concise error message from an HTTP response."""
251
+ if response is None:
252
+ return "No response body"
253
+ try:
254
+ payload = response.json()
255
+ if isinstance(payload, dict):
256
+ msg = payload.get("error") or payload.get("message")
257
+ if msg:
258
+ return str(msg)
259
+ return json.dumps(payload, ensure_ascii=False)
260
+ except Exception:
261
+ text = getattr(response, "text", "") or ""
262
+ text = text.strip()
263
+ return text if text else "Empty response body"
264
264
 
265
265
 
266
266
  def write_environments_lock(path: Path, dependencies: List[str]) -> None:
267
- """Write the environments lock file (YAML) next to `vuer_environments`."""
268
- deps = dedupe_keep_order(dependencies)
269
- lines = [
270
- "# This file is generated by `vuer sync`.",
271
- "# DO NOT EDIT THIS FILE MANUALLY.",
272
- "# This file is maintained by the system.",
273
- "# It lists the resolved (deduplicated) environment dependencies.",
274
- "",
275
- "environments:",
276
- ]
277
- for dep in deps:
278
- escaped = dep.replace('"', '\\"')
279
- lines.append(f' - "{escaped}"')
280
- path.write_text("\n".join(lines) + "\n", encoding="utf-8")
267
+ """Write the environments lock file (YAML) next to `vuer_environments`."""
268
+ deps = dedupe_keep_order(dependencies)
269
+ lines = [
270
+ "# This file is generated by `vuer sync`.",
271
+ "# DO NOT EDIT THIS FILE MANUALLY.",
272
+ "# This file is maintained by the system.",
273
+ "# It lists the resolved (deduplicated) environment dependencies.",
274
+ "",
275
+ "environments:",
276
+ ]
277
+ for dep in deps:
278
+ escaped = dep.replace('"', '\\"')
279
+ lines.append(f' - "{escaped}"')
280
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
281
281
 
282
282
 
283
283
  def dedupe_keep_order(items: List[str]) -> List[str]:
284
- """Deduplicate list while preserving first-seen order."""
285
- return list(dict.fromkeys(items))
284
+ """Deduplicate list while preserving first-seen order."""
285
+ return list(dict.fromkeys(items))
286
286
 
287
287
 
288
288
  def read_environments_lock(path: Path) -> List[str]:
289
- """Read environments from environments-lock.yaml.
290
-
291
- The file format is expected to be a YAML list under `environments:`. This
292
- reader implements a minimal parser that extracts entries starting with '-'.
293
- """
294
- if not path.exists():
295
- return []
296
- text = path.read_text(encoding="utf-8")
297
- lines = []
298
- for raw in text.splitlines():
299
- s = raw.strip()
300
- if s.startswith("-"):
301
- item = s[1:].strip()
302
- # strip optional quotes
303
- if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
304
- item = item[1:-1]
305
- lines.append(item)
306
- return lines
307
-
308
-
309
- def remove_unneeded_env_dirs(output_dir: Path, previous: List[str], desired: List[str]) -> int:
310
- """Remove env directories that were previously synced but are no longer desired.
311
-
312
- We only remove directories that correspond to entries in the old dependencies.toml,
313
- to avoid deleting unrelated user folders under Module/.
314
- """
315
- prev_set = set(previous)
316
- desired_set = set(desired)
317
- to_remove = [d for d in previous if d in prev_set and d not in desired_set]
318
- removed = 0
319
- for env_spec in to_remove:
320
- name, version = parse_env_spec(env_spec)
321
- env_dir = output_dir / name / version
322
- if env_dir.exists() and env_dir.is_dir():
323
- shutil.rmtree(env_dir, ignore_errors=True)
324
- removed += 1
325
- # If parent directory (name) is now empty, remove it as well.
326
- parent_dir = output_dir / name
327
- try:
328
- if parent_dir.exists() and parent_dir.is_dir() and not any(parent_dir.iterdir()):
329
- shutil.rmtree(parent_dir, ignore_errors=True)
330
- # Do not increment removed again — count represents removed version dirs.
331
- except Exception:
332
- # If listing or removal fails for any reason, ignore to avoid breaking sync.
333
- pass
334
- return removed
289
+ """Read environments from environments-lock.yaml.
290
+
291
+ The file format is expected to be a YAML list under `environments:`. This
292
+ reader implements a minimal parser that extracts entries starting with '-'.
293
+ """
294
+ if not path.exists():
295
+ return []
296
+ text = path.read_text(encoding="utf-8")
297
+ lines = []
298
+ for raw in text.splitlines():
299
+ s = raw.strip()
300
+ if s.startswith("-"):
301
+ item = s[1:].strip()
302
+ # strip optional quotes
303
+ if (item.startswith('"') and item.endswith('"')) or (
304
+ item.startswith("'") and item.endswith("'")
305
+ ):
306
+ item = item[1:-1]
307
+ lines.append(item)
308
+ return lines
309
+
310
+
311
+ def remove_unneeded_env_dirs(
312
+ output_dir: Path, previous: List[str], desired: List[str]
313
+ ) -> int:
314
+ """Remove env directories that were previously synced but are no longer desired.
315
+
316
+ We only remove directories that correspond to entries in the old dependencies.toml,
317
+ to avoid deleting unrelated user folders under Module/.
318
+ """
319
+ prev_set = set(previous)
320
+ desired_set = set(desired)
321
+ to_remove = [d for d in previous if d in prev_set and d not in desired_set]
322
+ removed = 0
323
+ for env_spec in to_remove:
324
+ name, version = parse_env_spec(env_spec)
325
+ env_dir = output_dir / name / version
326
+ if env_dir.exists() and env_dir.is_dir():
327
+ shutil.rmtree(env_dir, ignore_errors=True)
328
+ removed += 1
329
+ # If parent directory (name) is now empty, remove it as well.
330
+ parent_dir = output_dir / name
331
+ try:
332
+ if (
333
+ parent_dir.exists() and parent_dir.is_dir() and not any(parent_dir.iterdir())
334
+ ):
335
+ shutil.rmtree(parent_dir, ignore_errors=True)
336
+ # Do not increment removed again — count represents removed version dirs.
337
+ except Exception:
338
+ # If listing or removal fails for any reason, ignore to avoid breaking sync.
339
+ pass
340
+ return removed
335
341
 
336
342
 
337
343
  def collect_all_environments(
338
- direct_deps: List[str], dependencies_data: List[str]
344
+ direct_deps: List[str], dependencies_data: List[str]
339
345
  ) -> List[str]:
340
- """Collect all environments (direct + transitive) and deduplicate.
346
+ """Collect all environments (direct + transitive) and deduplicate.
341
347
 
342
- Args:
343
- direct_deps: Direct dependencies from environment.json
344
- dependencies_data: Flat list of deps returned by backend (may include duplicates)
348
+ Args:
349
+ direct_deps: Direct dependencies from environment.json
350
+ dependencies_data: Flat list of deps returned by backend (may include duplicates)
345
351
 
346
- Returns:
347
- List of unique environment specs (direct deps first)
348
- """
349
- # Ensure direct deps stay first, then append backend deps in their given order.
350
- return dedupe_keep_order(list(direct_deps) + list(dependencies_data))
352
+ Returns:
353
+ List of unique environment specs (direct deps first)
354
+ """
355
+ # Ensure direct deps stay first, then append backend deps in their given order.
356
+ return dedupe_keep_order(list(direct_deps) + list(dependencies_data))