vuer-cli 0.0.1__py3-none-any.whl → 0.0.3__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/main.py ADDED
@@ -0,0 +1,106 @@
1
+ """Vuer CLI - Environment Manager for Vuer Hub."""
2
+
3
+ import sys
4
+ from typing import Dict, Iterable, List
5
+
6
+ from params_proto import proto
7
+
8
+ from .add import Add
9
+ from .envs_publish import EnvsPublish
10
+ from .envs_pull import EnvsPull
11
+ from .remove import Remove
12
+ from .sync import Sync
13
+ from .upgrade import Upgrade
14
+
15
+
16
+ def _normalize_subcommand_args() -> None:
17
+ """Allow users to omit the `--command.` prefix for subcommand options."""
18
+ argv = sys.argv
19
+ if len(argv) < 2:
20
+ return
21
+
22
+ cmd = argv[1]
23
+ cmd_option_map: Dict[str, Iterable[str]] = {
24
+ "sync": {"output", "timeout"},
25
+ "add": {"env", "name", "version"},
26
+ "remove": {"env"},
27
+ "upgrade": {"env", "version"},
28
+ "envs-publish": {"directory", "timeout", "tag", "dry-run"},
29
+ "envs-pull": {"flag", "output", "filename", "version", "timeout",
30
+ "skip-progress"},
31
+ }
32
+ options = cmd_option_map.get(cmd)
33
+ if not options:
34
+ return
35
+
36
+ new_argv: List[str] = [argv[0], argv[1]]
37
+
38
+ # Special handling for positional env spec for `add`, `remove`, and `upgrade`:
39
+ # vuer add some-env@v1.2.3
40
+ # vuer remove some-env@v1.2.3
41
+ # vuer upgrade some-environment-name
42
+ # Convert the first non-flag token after the subcommand into:
43
+ # --command.env <value>
44
+ i = 2
45
+ if cmd in {"add", "remove", "upgrade"} and len(argv) > 2:
46
+ first = argv[2]
47
+ if not first.startswith("-"):
48
+ new_argv.append("--command.env")
49
+ new_argv.append(first)
50
+ i = 3
51
+
52
+ while i < len(argv):
53
+ token = argv[i]
54
+ if token.startswith("--") and not token.startswith("--command."):
55
+ if "=" in token:
56
+ name_part, value_part = token[2:].split("=", 1)
57
+ flag_name = name_part
58
+ remainder = "=" + value_part
59
+ else:
60
+ flag_name = token[2:]
61
+ remainder = ""
62
+
63
+ if flag_name in options:
64
+ new_argv.append(f"--command.{flag_name}{remainder}")
65
+ else:
66
+ new_argv.append(token)
67
+ else:
68
+ new_argv.append(token)
69
+ i += 1
70
+
71
+ sys.argv = new_argv
72
+
73
+
74
+ def entrypoint() -> int:
75
+ """Console script entry point (wrapper)."""
76
+ _normalize_subcommand_args()
77
+ return _cli_entrypoint() or 0
78
+
79
+
80
+ @proto.cli(prog="vuer")
81
+ def _cli_entrypoint(
82
+ command: Sync | Add | Remove | Upgrade | EnvsPublish | EnvsPull,
83
+ ):
84
+ """Vuer Hub Environment Manager.
85
+
86
+ Available commands:
87
+ sync - Sync environments from environment.json dependencies (like npm install)
88
+ add - Add an environment to environment.json and run sync
89
+ remove - Remove an environment from environment.json and run sync
90
+ upgrade - Upgrade an environment to the latest version
91
+ envs-publish - Publish an environment to the Vuer Hub
92
+ envs-pull - Pull/download an environment from the Vuer Hub
93
+
94
+ Examples:
95
+ vuer sync Sync all dependencies from environment.json
96
+ vuer add my-env/1.2.3 Add an environment and sync
97
+ vuer remove my-env/1.2.3 Remove an environment and sync
98
+ vuer upgrade my-env Upgrade an environment to latest version
99
+ vuer envs-publish Publish current environment to hub
100
+ vuer envs-pull my-env/1.2.3 Pull an environment from hub
101
+
102
+ Environment variables:
103
+ VUER_HUB_URL - Base URL of the Vuer Hub API
104
+ VUER_AUTH_TOKEN - JWT token for API authentication
105
+ """
106
+ return command()
vuer_cli/remove.py ADDED
@@ -0,0 +1,105 @@
1
+ """Remove command - remove an environment spec from environment.json then sync."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ # typing imports not required
6
+
7
+ import json
8
+
9
+ from .sync import Sync, read_environments_lock
10
+ from .utils import print_error, parse_env_spec, normalize_env_spec
11
+
12
+
13
+ # Use shared parser from utils; legacy '@' syntax is not supported.
14
+
15
+
16
+ @dataclass
17
+ class Remove:
18
+ """Remove an environment from environment.json and run `vuer sync`.
19
+
20
+ Example:
21
+ vuer remove some-environment/v1.2.3
22
+ """
23
+
24
+ # Primary usage is positional: `vuer remove name/version`.
25
+ env: str = "" # Environment spec to remove, e.g. "some-environment/v1.2.3"
26
+
27
+ def __call__(self) -> int:
28
+ """Execute remove command."""
29
+ try:
30
+ env_spec = self.env
31
+ if not env_spec:
32
+ raise ValueError(
33
+ "Missing environment spec. Usage: vuer remove some-environment/v1.2.3"
34
+ )
35
+
36
+ name, version = parse_env_spec(env_spec)
37
+ env_spec_normalized = normalize_env_spec(f"{name}/{version}")
38
+
39
+ cwd = Path.cwd()
40
+ module_dir = cwd / "vuer_environments"
41
+ lock_path = cwd / "environments-lock.yaml"
42
+
43
+ # Step 2: Ensure vuer_environments/dependencies.toml exists
44
+ if not module_dir.exists() or not lock_path.exists():
45
+ raise FileNotFoundError(
46
+ "vuer_environments directory or environments-lock.yaml not found. "
47
+ "Please run `vuer sync` first to generate environments-lock.yaml."
48
+ )
49
+ existing_deps = read_environments_lock(lock_path)
50
+ if env_spec_normalized not in existing_deps:
51
+ print(f"[INFO] Environment {env_spec_normalized} is not present in {lock_path}")
52
+ return 0
53
+
54
+ # Step 3: Remove from environment.json dependencies, then run sync
55
+ env_json_path = cwd / "environment.json"
56
+ if not env_json_path.exists():
57
+ raise FileNotFoundError(
58
+ "environment.json not found. Cannot remove dependency."
59
+ )
60
+
61
+ with env_json_path.open("r", encoding="utf-8") as f:
62
+ try:
63
+ data = json.load(f)
64
+ except json.JSONDecodeError as e:
65
+ raise ValueError(
66
+ f"Invalid environment.json: {e}"
67
+ ) from e
68
+
69
+ deps = data.get("dependencies")
70
+ if deps is None:
71
+ deps = {}
72
+ if not isinstance(deps, dict):
73
+ raise ValueError(
74
+ "environment.json 'dependencies' field must be an object"
75
+ )
76
+
77
+ # Remove the dependency if present and version matches exactly.
78
+ current_version = deps.get(name)
79
+ if current_version is None:
80
+ print(f"[INFO] Dependency {env_spec_normalized} not found in environment.json. Skipping removal.")
81
+ else:
82
+ # Only remove if the version in environment.json matches the requested version.
83
+ if current_version != version:
84
+ print(
85
+ f"[INFO] Skipping removal: environment '{name}' is pinned to version "
86
+ f"'{current_version}' in environment.json (requested '{version}')."
87
+ )
88
+ else:
89
+ deps.pop(name, None)
90
+ print(f"[INFO] Removed {env_spec_normalized} from environment.json dependencies.")
91
+
92
+ data["dependencies"] = deps
93
+ with env_json_path.open("w", encoding="utf-8") as f:
94
+ json.dump(data, f, indent=2, ensure_ascii=False)
95
+ f.write("\n")
96
+
97
+ print("[INFO] Running sync to reconcile vuer_environments/ with updated dependencies...")
98
+ return Sync()()
99
+
100
+ except (FileNotFoundError, ValueError, RuntimeError) as e:
101
+ print_error(str(e))
102
+ return 1
103
+ except Exception as e:
104
+ print_error(f"Unexpected error: {e}")
105
+ return 1
vuer_cli/sync.py ADDED
@@ -0,0 +1,350 @@
1
+ """Sync command - pull all environments from environment.json dependencies (npm-style)."""
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, List
8
+
9
+ from .envs_publish import Hub
10
+ from .envs_pull import pull_from_registry
11
+ from .utils import (
12
+ is_dry_run,
13
+ print_error,
14
+ parse_env_spec,
15
+ )
16
+
17
+
18
+ @dataclass
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
147
+
148
+
149
+ # -- Helper functions --
150
+
151
+
152
+ def parse_dependencies(env_json_path: Path) -> List[str]:
153
+ """Parse environment.json and extract dependencies list.
154
+
155
+ Returns:
156
+ List of dependency specs like ["some-dependency/^1.2.3", ...]
157
+ """
158
+ 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}")
174
+
175
+ return dependencies
176
+
177
+
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
+
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
247
+
248
+
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"
264
+
265
+
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")
281
+
282
+
283
+ def dedupe_keep_order(items: List[str]) -> List[str]:
284
+ """Deduplicate list while preserving first-seen order."""
285
+ return list(dict.fromkeys(items))
286
+
287
+
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
335
+
336
+
337
+ def collect_all_environments(
338
+ direct_deps: List[str], dependencies_data: List[str]
339
+ ) -> List[str]:
340
+ """Collect all environments (direct + transitive) and deduplicate.
341
+
342
+ Args:
343
+ direct_deps: Direct dependencies from environment.json
344
+ dependencies_data: Flat list of deps returned by backend (may include duplicates)
345
+
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))