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/__init__.py +20 -0
- vuer_cli/add.py +93 -0
- vuer_cli/envs_publish.py +371 -0
- vuer_cli/envs_pull.py +206 -0
- vuer_cli/main.py +106 -0
- vuer_cli/remove.py +105 -0
- vuer_cli/sync.py +350 -0
- vuer_cli/upgrade.py +159 -0
- vuer_cli/utils.py +101 -0
- vuer_cli-0.0.3.dist-info/METADATA +183 -0
- vuer_cli-0.0.3.dist-info/RECORD +14 -0
- vuer_cli-0.0.1.dist-info/METADATA +0 -180
- vuer_cli-0.0.1.dist-info/RECORD +0 -5
- {vuer_cli-0.0.1.dist-info → vuer_cli-0.0.3.dist-info}/WHEEL +0 -0
- {vuer_cli-0.0.1.dist-info → vuer_cli-0.0.3.dist-info}/entry_points.txt +0 -0
- {vuer_cli-0.0.1.dist-info → vuer_cli-0.0.3.dist-info}/licenses/LICENSE +0 -0
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))
|