mirrorneuron-cli 1.1.3__tar.gz → 1.1.4__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.
Files changed (47) hide show
  1. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/PKG-INFO +1 -1
  2. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/PKG-INFO +1 -1
  3. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_cmds.py +114 -0
  4. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/run_cmds.py +2 -0
  5. mirrorneuron_cli-1.1.4/mn_cli/libs/run_manifest.py +253 -0
  6. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_run_cmds.py +6 -2
  7. mirrorneuron_cli-1.1.4/tests/test_run_helpers.py +155 -0
  8. mirrorneuron_cli-1.1.3/mn_cli/libs/run_manifest.py +0 -72
  9. mirrorneuron_cli-1.1.3/tests/test_run_helpers.py +0 -79
  10. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.github/workflows/ci.yml +0 -0
  11. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.github/workflows/release.yml +0 -0
  12. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.gitignore +0 -0
  13. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/LICENSE +0 -0
  14. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/README.md +0 -0
  15. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/RELEASE.md +0 -0
  16. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
  17. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  18. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  19. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  20. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  21. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/__init__.py +0 -0
  22. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/config.py +0 -0
  23. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/error_handler.py +0 -0
  24. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/__init__.py +0 -0
  25. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_observability.py +0 -0
  26. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_repository.py +0 -0
  27. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/job_cmds.py +0 -0
  28. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/run_logs.py +0 -0
  29. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/sys_cmds.py +0 -0
  30. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/ui.py +0 -0
  31. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/logging_config.py +0 -0
  32. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/main.py +0 -0
  33. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/server_cmds.py +0 -0
  34. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/shared.py +0 -0
  35. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/update_cmds.py +0 -0
  36. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/pyproject.toml +0 -0
  37. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/check-release-artifacts.sh +0 -0
  38. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/make-release-zip.sh +0 -0
  39. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/validate-version-tag.sh +0 -0
  40. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/setup.cfg +0 -0
  41. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/conftest.py +0 -0
  42. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_blueprint_cmds.py +0 -0
  43. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_blueprint_repository.py +0 -0
  44. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_job_cmds.py +0 -0
  45. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_server_cmds.py +0 -0
  46. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_sys_cmds.py +0 -0
  47. {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_update_cmds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.3
3
+ Version: 1.1.4
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.3
3
+ Version: 1.1.4
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -2,6 +2,7 @@ import os
2
2
  import json
3
3
  import shutil
4
4
  import subprocess
5
+ import sys
5
6
  import time
6
7
  from pathlib import Path
7
8
  from typing import Any, Optional
@@ -40,6 +41,7 @@ from mn_cli.libs.blueprint_repository import (
40
41
  )
41
42
  from mn_cli.shared import console, logger
42
43
  from mn_cli.libs.run_cmds import run_bundle as _run_bundle
44
+ from mn_cli.libs.run_manifest import load_blueprint_config as _load_blueprint_config
43
45
 
44
46
  blueprint_app = typer.Typer(help="Manage and run MirrorNeuron blueprints")
45
47
  _PATCH_COMPAT = (subprocess, _git_checkout, _git_fetch)
@@ -128,6 +130,7 @@ def _run_resolved_blueprint(
128
130
  if revision:
129
131
  console.print(f"Blueprint revision: {revision}")
130
132
  bundle_path = _prepare_blueprint_bundle_for_run(blueprint_dir, manifest, shared_run_id)
133
+ config_overrides = _collect_init_config_review_overrides(bundle_path, manifest)
131
134
  _run_bundle(
132
135
  str(bundle_path),
133
136
  follow_seconds=follow_seconds,
@@ -141,6 +144,7 @@ def _run_resolved_blueprint(
141
144
  "blueprint_revision": revision,
142
145
  "blueprint_source": source_label,
143
146
  },
147
+ config_overrides=config_overrides,
144
148
  )
145
149
 
146
150
 
@@ -171,6 +175,116 @@ def _run_local_blueprint_target(
171
175
  return True
172
176
 
173
177
 
178
+ def _collect_init_config_review_overrides(
179
+ bundle_path: Path,
180
+ manifest: dict[str, Any],
181
+ ) -> dict[str, Any] | None:
182
+ review = _manifest_init_config_review(manifest)
183
+ if not isinstance(review, dict):
184
+ return None
185
+ fields = review.get("fields")
186
+ if not isinstance(fields, list) or not fields:
187
+ return None
188
+ if _env_flag("MN_BLUEPRINT_SKIP_INIT_CONFIG_REVIEW"):
189
+ return None
190
+ if not sys.stdin.isatty():
191
+ if review.get("required") is True:
192
+ console.print("[yellow]Blueprint config review requested; keeping current config in this non-interactive run.[/yellow]")
193
+ return None
194
+
195
+ config = _load_blueprint_config(bundle_path) or {}
196
+ overrides: dict[str, Any] = {}
197
+ console.print("[bold]Review blueprint config before launch[/bold]")
198
+ instruction = review.get("instruction")
199
+ if isinstance(instruction, str) and instruction.strip():
200
+ console.print(instruction.strip())
201
+
202
+ for raw_field in fields:
203
+ if not isinstance(raw_field, dict):
204
+ continue
205
+ path = raw_field.get("path")
206
+ if not isinstance(path, str) or not path.strip():
207
+ continue
208
+ path = path.strip()
209
+ label = str(raw_field.get("label") or path)
210
+ description = raw_field.get("description")
211
+ current = _config_path_get(config, path)
212
+ fallback = raw_field.get("default")
213
+ default_value = current if current is not None else fallback
214
+ if isinstance(description, str) and description.strip():
215
+ console.print(f"{label}: {description.strip()}")
216
+ if default_value is None:
217
+ response = typer.prompt(label, default="", show_default=False)
218
+ if response == "":
219
+ continue
220
+ else:
221
+ response = typer.prompt(label, default=str(default_value), show_default=True)
222
+ parsed = _parse_review_value(response, default_value)
223
+ if parsed != current:
224
+ _config_path_set(overrides, path, parsed)
225
+
226
+ return overrides or None
227
+
228
+
229
+ def _manifest_init_config_review(manifest: dict[str, Any]) -> Any:
230
+ if "init_config_review" in manifest:
231
+ return manifest.get("init_config_review")
232
+ metadata = manifest.get("metadata")
233
+ if isinstance(metadata, dict):
234
+ return metadata.get("init_config_review")
235
+ return None
236
+
237
+
238
+ def _config_path_get(config: dict[str, Any], dotted_path: str) -> Any:
239
+ cursor: Any = config
240
+ for part in dotted_path.split("."):
241
+ if not isinstance(cursor, dict) or part not in cursor:
242
+ return None
243
+ cursor = cursor[part]
244
+ return cursor
245
+
246
+
247
+ def _config_path_set(config: dict[str, Any], dotted_path: str, value: Any) -> None:
248
+ cursor = config
249
+ parts = [part for part in dotted_path.split(".") if part]
250
+ for part in parts[:-1]:
251
+ next_value = cursor.get(part)
252
+ if not isinstance(next_value, dict):
253
+ next_value = {}
254
+ cursor[part] = next_value
255
+ cursor = next_value
256
+ if parts:
257
+ cursor[parts[-1]] = value
258
+
259
+
260
+ def _parse_review_value(value: str, default_value: Any) -> Any:
261
+ if isinstance(default_value, bool):
262
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
263
+ if isinstance(default_value, int) and not isinstance(default_value, bool):
264
+ try:
265
+ return int(value)
266
+ except ValueError:
267
+ return value
268
+ if isinstance(default_value, float):
269
+ try:
270
+ return float(value)
271
+ except ValueError:
272
+ return value
273
+ if isinstance(default_value, (dict, list)):
274
+ try:
275
+ return json.loads(value)
276
+ except json.JSONDecodeError:
277
+ return value
278
+ return value
279
+
280
+
281
+ def _env_flag(name: str) -> bool:
282
+ value = os.environ.get(name)
283
+ if value is None:
284
+ return False
285
+ return value.strip().lower() in {"1", "true", "yes", "on"}
286
+
287
+
174
288
  def _print_run_table(runs: list[dict[str, Any]]) -> None:
175
289
  if not runs:
176
290
  console.print("[yellow]No blueprint runs found.[/yellow]")
@@ -331,6 +331,7 @@ def run_bundle(
331
331
  follow_seconds: Optional[float] = None,
332
332
  env_overrides: Optional[dict[str, str]] = None,
333
333
  submission_metadata: Optional[dict[str, Any]] = None,
334
+ config_overrides: Optional[dict[str, Any]] = None,
334
335
  ):
335
336
  """Run a bundle after applying optional runtime metadata and environment."""
336
337
  try:
@@ -374,6 +375,7 @@ def run_bundle(
374
375
  manifest_dict,
375
376
  env_overrides=env_overrides,
376
377
  submission_metadata=submission_metadata,
378
+ config_overrides=config_overrides,
377
379
  )
378
380
  manifest = json.dumps(manifest_dict)
379
381
 
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+
8
+ def prepare_manifest_for_submission(
9
+ bundle_dir: Path,
10
+ manifest_dict: dict[str, Any],
11
+ *,
12
+ env_overrides: Optional[dict[str, str]] = None,
13
+ submission_metadata: Optional[dict[str, Any]] = None,
14
+ config_overrides: Optional[dict[str, Any]] = None,
15
+ ) -> dict[str, Any]:
16
+ prepared = json.loads(json.dumps(manifest_dict))
17
+ config = load_blueprint_config(bundle_dir, config_overrides=config_overrides)
18
+ if config is not None:
19
+ apply_manifest_config_bindings(prepared, config)
20
+ runtime_env = blueprint_runtime_environment(
21
+ bundle_dir,
22
+ config=config,
23
+ config_overrides=config_overrides,
24
+ )
25
+ runtime_env.update({key: str(value) for key, value in (env_overrides or {}).items() if value is not None})
26
+ if runtime_env:
27
+ inject_node_environment(prepared, runtime_env)
28
+ metadata = dict(submission_metadata or {})
29
+ if metadata:
30
+ prepared.setdefault("metadata", {}).setdefault("mn_cli", {}).update(metadata)
31
+ return prepared
32
+
33
+
34
+ def blueprint_runtime_environment(
35
+ bundle_dir: Path,
36
+ *,
37
+ config: Optional[dict[str, Any]] = None,
38
+ config_overrides: Optional[dict[str, Any]] = None,
39
+ ) -> dict[str, str]:
40
+ env: dict[str, str] = {}
41
+ if config is None:
42
+ config = load_blueprint_config(bundle_dir, config_overrides=config_overrides)
43
+ if config is not None:
44
+ env["MN_BLUEPRINT_CONFIG_JSON"] = json.dumps(config, sort_keys=True)
45
+ projected_config = load_blueprint_config_overwrites(bundle_dir, config_overrides=config_overrides)
46
+ if projected_config is not None:
47
+ env.update(config_to_environment(projected_config))
48
+
49
+ scenario_path = bundle_dir / "scenario.json"
50
+ if scenario_path.exists():
51
+ env["MN_BLUEPRINT_SCENARIO_JSON"] = scenario_path.read_text(encoding="utf-8")
52
+ return env
53
+
54
+
55
+ def apply_manifest_config_bindings(manifest: dict[str, Any], config: dict[str, Any]) -> None:
56
+ bindings = config.get("manifest_config_bindings") or []
57
+ if not isinstance(bindings, list):
58
+ return
59
+ for binding in bindings:
60
+ if not isinstance(binding, dict):
61
+ continue
62
+ config_path = binding.get("config_path") or binding.get("from")
63
+ manifest_path = binding.get("manifest_path") or binding.get("to")
64
+ if not isinstance(config_path, str) or not isinstance(manifest_path, str):
65
+ continue
66
+ value = config_path_get(config, config_path)
67
+ if value is None and not binding.get("allow_null", False):
68
+ continue
69
+ if binding.get("stringify") is True:
70
+ value = str(value).lower() if isinstance(value, bool) else str(value)
71
+ set_manifest_path(manifest, manifest_path, value)
72
+
73
+
74
+ def config_to_environment(config: dict[str, Any]) -> dict[str, str]:
75
+ env: dict[str, str] = {}
76
+ for path, names in (
77
+ ("video_source.uri", ("VIDEO_SOURCE_URI",)),
78
+ ("video_source.transport", ("VIDEO_SOURCE_TRANSPORT",)),
79
+ ("video_source.codec", ("VIDEO_SOURCE_CODEC",)),
80
+ ("video_source.camera_id", ("VIDEO_SOURCE_CAMERA_ID",)),
81
+ ("video_source.frame_sample_seconds", ("FRAME_SAMPLE_SECONDS",)),
82
+ ("video_source.frame_jpeg_max_width", ("FRAME_JPEG_MAX_WIDTH",)),
83
+ ("vl_model.base_url", ("VL_MODEL_BASE_URL", "OLLAMA_BASE_URL")),
84
+ ("vl_model.model", ("VL_MODEL_NAME", "OLLAMA_MODEL")),
85
+ ("vl_model.timeout_seconds", ("VL_MODEL_TIMEOUT_SECONDS", "OLLAMA_TIMEOUT_SECONDS")),
86
+ ("vl_model.temperature", ("VL_MODEL_TEMPERATURE", "OLLAMA_TEMPERATURE")),
87
+ ("llm.api_base", ("MN_LLM_API_BASE", "LITELLM_API_BASE")),
88
+ ("llm.model", ("MN_LLM_MODEL", "LITELLM_MODEL")),
89
+ ("llm.timeout_seconds", ("MN_LLM_TIMEOUT_SECONDS", "LITELLM_TIMEOUT_SECONDS")),
90
+ ("llm.max_tokens", ("MN_LLM_MAX_TOKENS", "LITELLM_MAX_TOKENS")),
91
+ ("llm.num_retries", ("MN_LLM_NUM_RETRIES", "LITELLM_NUM_RETRIES")),
92
+ ):
93
+ value = config_path_get(config, path)
94
+ if value is None:
95
+ continue
96
+ for name in names:
97
+ env[name] = str(value)
98
+ return env
99
+
100
+
101
+ def set_manifest_path(target: Any, dotted_path: str, value: Any) -> None:
102
+ parts = [part for part in dotted_path.split(".") if part]
103
+ _set_path(target, parts, value)
104
+
105
+
106
+ def _set_path(cursor: Any, parts: list[str], value: Any) -> None:
107
+ if not parts:
108
+ return
109
+ part = parts[0]
110
+ rest = parts[1:]
111
+
112
+ if isinstance(cursor, list):
113
+ for item in _list_targets(cursor, part):
114
+ _set_path(item, rest, value)
115
+ return
116
+
117
+ if not isinstance(cursor, dict):
118
+ return
119
+
120
+ if len(parts) == 1:
121
+ cursor[part] = value
122
+ return
123
+
124
+ next_value = cursor.get(part)
125
+ if isinstance(next_value, list):
126
+ _set_path(next_value, rest, value)
127
+ return
128
+ if not isinstance(next_value, dict):
129
+ next_value = {}
130
+ cursor[part] = next_value
131
+ _set_path(next_value, rest, value)
132
+
133
+
134
+ def _list_targets(items: list[Any], selector: str) -> list[Any]:
135
+ if selector == "*":
136
+ return [item for item in items if isinstance(item, dict)]
137
+ if selector.isdigit():
138
+ index = int(selector)
139
+ if 0 <= index < len(items):
140
+ return [items[index]]
141
+ return []
142
+ if selector.endswith("*"):
143
+ prefix = selector[:-1]
144
+ return [
145
+ item
146
+ for item in items
147
+ if isinstance(item, dict) and str(item.get("node_id") or "").startswith(prefix)
148
+ ]
149
+ return [
150
+ item
151
+ for item in items
152
+ if isinstance(item, dict) and (item.get("node_id") == selector or item.get("edge_id") == selector)
153
+ ]
154
+
155
+
156
+ def config_path_get(config: dict[str, Any], dotted_path: str) -> Any:
157
+ cursor: Any = config
158
+ for part in dotted_path.split("."):
159
+ if not isinstance(cursor, dict) or part not in cursor:
160
+ return None
161
+ cursor = cursor[part]
162
+ return cursor
163
+
164
+
165
+ def load_blueprint_config(
166
+ bundle_dir: Path,
167
+ *,
168
+ config_overrides: Optional[dict[str, Any]] = None,
169
+ ) -> dict[str, Any] | None:
170
+ config: dict[str, Any] = {}
171
+ loaded = False
172
+ for path in (
173
+ bundle_dir / "config" / "default.json",
174
+ bundle_dir / "config" / "overwrite.json",
175
+ ):
176
+ if path.exists():
177
+ config = deep_merge(config, read_json_object(path))
178
+ loaded = True
179
+ if config_overrides:
180
+ config = deep_merge(config, config_overrides)
181
+ loaded = True
182
+ return config if loaded else None
183
+
184
+
185
+ def load_blueprint_config_overwrites(
186
+ bundle_dir: Path,
187
+ *,
188
+ config_overrides: Optional[dict[str, Any]] = None,
189
+ ) -> dict[str, Any] | None:
190
+ config: dict[str, Any] = {}
191
+ loaded = False
192
+ overwrite_path = bundle_dir / "config" / "overwrite.json"
193
+ if overwrite_path.exists():
194
+ config = deep_merge(config, read_json_object(overwrite_path))
195
+ loaded = True
196
+ if config_overrides:
197
+ config = deep_merge(config, config_overrides)
198
+ loaded = True
199
+ return config if loaded else None
200
+
201
+
202
+ def inject_node_environment(manifest: dict[str, Any], env: dict[str, str]) -> None:
203
+ for node in manifest.get("nodes") or []:
204
+ if not isinstance(node, dict):
205
+ continue
206
+ config = node.setdefault("config", {})
207
+ if not isinstance(config, dict):
208
+ continue
209
+ environment = config.setdefault("environment", {})
210
+ if not isinstance(environment, dict):
211
+ continue
212
+ environment.update(env)
213
+ add_mn_llm_aliases(environment)
214
+
215
+
216
+ def add_mn_llm_aliases(environment: dict[str, Any]) -> None:
217
+ for legacy, primary in (
218
+ ("LITELLM_MODEL", "MN_LLM_MODEL"),
219
+ ("LITELLM_API_BASE", "MN_LLM_API_BASE"),
220
+ ("LITELLM_API_KEY", "MN_LLM_API_KEY"),
221
+ ("LITELLM_TIMEOUT_SECONDS", "MN_LLM_TIMEOUT_SECONDS"),
222
+ ("LITELLM_MAX_TOKENS", "MN_LLM_MAX_TOKENS"),
223
+ ("LITELLM_NUM_RETRIES", "MN_LLM_NUM_RETRIES"),
224
+ ("LITELLM_RETRY_BACKOFF_SECONDS", "MN_LLM_RETRY_BACKOFF_SECONDS"),
225
+ ):
226
+ if primary not in environment and legacy in environment:
227
+ environment[primary] = environment[legacy]
228
+
229
+
230
+ def run_mode_label(manifest: dict) -> str:
231
+ is_live = manifest.get("daemon") is True or manifest.get("policies", {}).get("stream_mode") == "live"
232
+ if is_live and manifest.get("daemon") is True:
233
+ return "Live daemon"
234
+ if is_live:
235
+ return "Live"
236
+ return "Batch"
237
+
238
+
239
+ def read_json_object(path: Path) -> dict[str, Any]:
240
+ decoded = json.loads(path.read_text(encoding="utf-8"))
241
+ if not isinstance(decoded, dict):
242
+ raise ValueError(f"{path} must contain a JSON object")
243
+ return decoded
244
+
245
+
246
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
247
+ result = dict(base)
248
+ for key, value in override.items():
249
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
250
+ result[key] = deep_merge(result[key], value)
251
+ else:
252
+ result[key] = value
253
+ return result
@@ -124,7 +124,8 @@ def test_run_injects_blueprint_config_scenario_and_run_id(mocker, tmp_path):
124
124
  }))
125
125
  config_dir = bundle_dir / "config"
126
126
  config_dir.mkdir()
127
- (config_dir / "default.json").write_text(json.dumps({"identity": {"blueprint_id": "bp-1"}}))
127
+ (config_dir / "default.json").write_text(json.dumps({"identity": {"blueprint_id": "bp-1"}, "video_source": {"uri": "default"}}))
128
+ (config_dir / "overwrite.json").write_text(json.dumps({"video_source": {"uri": "overwrite"}}))
128
129
  (bundle_dir / "scenario.json").write_text(json.dumps({"blueprint_id": "bp-1", "metrics": [], "actions": []}))
129
130
 
130
131
  result = runner.invoke(app, ["run", str(bundle_dir)])
@@ -132,7 +133,10 @@ def test_run_injects_blueprint_config_scenario_and_run_id(mocker, tmp_path):
132
133
  assert result.exit_code == 0
133
134
  manifest = json.loads(mock_submit.call_args.args[0])
134
135
  env = manifest["nodes"][0]["config"]["environment"]
135
- assert json.loads(env["MN_BLUEPRINT_CONFIG_JSON"])["identity"]["blueprint_id"] == "bp-1"
136
+ injected_config = json.loads(env["MN_BLUEPRINT_CONFIG_JSON"])
137
+ assert injected_config["identity"]["blueprint_id"] == "bp-1"
138
+ assert injected_config["video_source"]["uri"] == "overwrite"
139
+ assert env["VIDEO_SOURCE_URI"] == "overwrite"
136
140
  assert json.loads(env["MN_BLUEPRINT_SCENARIO_JSON"])["blueprint_id"] == "bp-1"
137
141
  assert "MN_BLUEPRINT_PRODUCT_JSON" not in env
138
142
  assert env["MN_LLM_MODEL"] == "ollama/nemotron3:33b"
@@ -0,0 +1,155 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from mn_cli.libs.run_logs import JobLogWriter, materialize_sent_email_copy
6
+ from mn_cli.libs.run_manifest import (
7
+ apply_manifest_config_bindings,
8
+ load_blueprint_config,
9
+ prepare_manifest_for_submission,
10
+ )
11
+
12
+
13
+ def test_prepare_manifest_for_submission_merges_runtime_env_and_metadata(tmp_path):
14
+ bundle_dir = tmp_path / "bundle"
15
+ bundle_dir.mkdir()
16
+ config_dir = bundle_dir / "config"
17
+ config_dir.mkdir()
18
+ (config_dir / "default.json").write_text(json.dumps({
19
+ "identity": {"blueprint_id": "bp"},
20
+ "vl_model": {"model": "default"},
21
+ "manifest_config_bindings": [
22
+ {
23
+ "config_path": "vl_model.model",
24
+ "manifest_path": "nodes.worker.config.environment.CUSTOM_MODEL",
25
+ }
26
+ ],
27
+ }))
28
+ (config_dir / "overwrite.json").write_text(json.dumps({"vl_model": {"model": "overwrite"}}))
29
+
30
+ manifest = {
31
+ "nodes": [
32
+ {
33
+ "node_id": "worker",
34
+ "config": {
35
+ "environment": {
36
+ "LITELLM_MODEL": "ollama/test",
37
+ "MN_LLM_API_KEY": "kept",
38
+ }
39
+ },
40
+ }
41
+ ]
42
+ }
43
+
44
+ prepared = prepare_manifest_for_submission(
45
+ bundle_dir,
46
+ manifest,
47
+ env_overrides={"MN_RUN_ID": "run-1"},
48
+ submission_metadata={"blueprint_id": "bp"},
49
+ config_overrides={"vl_model": {"base_url": "http://local"}},
50
+ )
51
+
52
+ env = prepared["nodes"][0]["config"]["environment"]
53
+ injected_config = json.loads(env["MN_BLUEPRINT_CONFIG_JSON"])
54
+ assert injected_config["identity"]["blueprint_id"] == "bp"
55
+ assert injected_config["vl_model"] == {"model": "overwrite", "base_url": "http://local"}
56
+ assert env["VL_MODEL_NAME"] == "overwrite"
57
+ assert env["OLLAMA_MODEL"] == "overwrite"
58
+ assert env["VL_MODEL_BASE_URL"] == "http://local"
59
+ assert env["CUSTOM_MODEL"] == "overwrite"
60
+ assert env["MN_RUN_ID"] == "run-1"
61
+ assert env["MN_LLM_MODEL"] == "ollama/test"
62
+ assert env["MN_LLM_API_KEY"] == "kept"
63
+ assert prepared["metadata"]["mn_cli"]["blueprint_id"] == "bp"
64
+
65
+
66
+ def test_blueprint_config_ignores_misnamed_overwrite_file(tmp_path):
67
+ bundle_dir = tmp_path / "bundle"
68
+ config_dir = bundle_dir / "config"
69
+ config_dir.mkdir(parents=True)
70
+ (config_dir / "default.json").write_text(json.dumps({"vl_model": {"model": "default"}}))
71
+ (config_dir / "overwrites.json").write_text(json.dumps({"vl_model": {"model": "wrong-name"}}))
72
+
73
+ config = load_blueprint_config(bundle_dir)
74
+
75
+ assert config == {"vl_model": {"model": "default"}}
76
+
77
+
78
+ @pytest.mark.parametrize("payload", ["[]", "{bad json"])
79
+ def test_blueprint_config_rejects_invalid_overwrite_data_format(tmp_path, payload):
80
+ bundle_dir = tmp_path / "bundle"
81
+ config_dir = bundle_dir / "config"
82
+ config_dir.mkdir(parents=True)
83
+ (config_dir / "default.json").write_text(json.dumps({"vl_model": {"model": "default"}}))
84
+ (config_dir / "overwrite.json").write_text(payload)
85
+
86
+ with pytest.raises((json.JSONDecodeError, ValueError)):
87
+ load_blueprint_config(bundle_dir)
88
+
89
+
90
+ def test_manifest_config_bindings_ignore_wrong_names():
91
+ manifest = {
92
+ "nodes": [
93
+ {
94
+ "node_id": "worker",
95
+ "config": {"environment": {"CUSTOM_MODEL": "keep"}},
96
+ }
97
+ ]
98
+ }
99
+ config = {
100
+ "vl_model": {"model": "overwrite"},
101
+ "manifest_config_bindings": [
102
+ {
103
+ "config_path": "vl_model.wrong_name",
104
+ "manifest_path": "nodes.worker.config.environment.CUSTOM_MODEL",
105
+ },
106
+ {
107
+ "config_path": "vl_model.model",
108
+ "manifest_path": "nodes.missing_worker.config.environment.NEW_MODEL",
109
+ },
110
+ ],
111
+ }
112
+
113
+ apply_manifest_config_bindings(manifest, config)
114
+
115
+ env = manifest["nodes"][0]["config"]["environment"]
116
+ assert env == {"CUSTOM_MODEL": "keep"}
117
+
118
+
119
+ def test_job_log_writer_deduplicates_events_and_records_web_ui_once():
120
+ writer = JobLogWriter("unit-run-helper")
121
+ event = {
122
+ "timestamp": "2026-05-01T00:00:00Z",
123
+ "type": "custom",
124
+ "payload": {"message_id": "m1", "web_ui": {"url": "http://localhost:1"}},
125
+ }
126
+
127
+ assert writer.write_event(event) is True
128
+ assert writer.write_event(event) is False
129
+ assert writer.record_web_ui_url(event) == "http://localhost:1"
130
+ assert writer.record_web_ui_url(event) is None
131
+
132
+
133
+ def test_materialize_sent_email_copy_uses_safe_host_paths(tmp_path):
134
+ materialize_sent_email_copy(
135
+ tmp_path,
136
+ {
137
+ "provider_id": "id/with spaces",
138
+ "sent_email_copy": {
139
+ "html_path": "../unsafe.html",
140
+ "text_content": "plain",
141
+ "html_content": "<p>Hello</p>",
142
+ "metadata": {"provider": "test"},
143
+ },
144
+ },
145
+ )
146
+
147
+ email_dir = tmp_path / "sent_emails"
148
+ assert (email_dir / "unsafe.html").read_text() == "<p>Hello</p>"
149
+ metadata = json.loads((email_dir / "id-with-spaces.json").read_text())
150
+ assert metadata["provider"] == "test"
151
+ assert PathLikeName(metadata["host_html_path"]) == "unsafe.html"
152
+
153
+
154
+ def PathLikeName(path: str) -> str:
155
+ return path.rsplit("/", 1)[-1]
@@ -1,72 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from pathlib import Path
5
- from typing import Any, Optional
6
-
7
-
8
- def prepare_manifest_for_submission(
9
- bundle_dir: Path,
10
- manifest_dict: dict[str, Any],
11
- *,
12
- env_overrides: Optional[dict[str, str]] = None,
13
- submission_metadata: Optional[dict[str, Any]] = None,
14
- ) -> dict[str, Any]:
15
- prepared = json.loads(json.dumps(manifest_dict))
16
- runtime_env = blueprint_runtime_environment(bundle_dir)
17
- runtime_env.update({key: str(value) for key, value in (env_overrides or {}).items() if value is not None})
18
- if runtime_env:
19
- inject_node_environment(prepared, runtime_env)
20
- metadata = dict(submission_metadata or {})
21
- if metadata:
22
- prepared.setdefault("metadata", {}).setdefault("mn_cli", {}).update(metadata)
23
- return prepared
24
-
25
-
26
- def blueprint_runtime_environment(bundle_dir: Path) -> dict[str, str]:
27
- env: dict[str, str] = {}
28
- for filename, env_name in (
29
- ("config/default.json", "MN_BLUEPRINT_CONFIG_JSON"),
30
- ("scenario.json", "MN_BLUEPRINT_SCENARIO_JSON"),
31
- ):
32
- path = bundle_dir / filename
33
- if path.exists():
34
- env[env_name] = path.read_text(encoding="utf-8")
35
- return env
36
-
37
-
38
- def inject_node_environment(manifest: dict[str, Any], env: dict[str, str]) -> None:
39
- for node in manifest.get("nodes") or []:
40
- if not isinstance(node, dict):
41
- continue
42
- config = node.setdefault("config", {})
43
- if not isinstance(config, dict):
44
- continue
45
- environment = config.setdefault("environment", {})
46
- if not isinstance(environment, dict):
47
- continue
48
- environment.update(env)
49
- add_mn_llm_aliases(environment)
50
-
51
-
52
- def add_mn_llm_aliases(environment: dict[str, Any]) -> None:
53
- for legacy, primary in (
54
- ("LITELLM_MODEL", "MN_LLM_MODEL"),
55
- ("LITELLM_API_BASE", "MN_LLM_API_BASE"),
56
- ("LITELLM_API_KEY", "MN_LLM_API_KEY"),
57
- ("LITELLM_TIMEOUT_SECONDS", "MN_LLM_TIMEOUT_SECONDS"),
58
- ("LITELLM_MAX_TOKENS", "MN_LLM_MAX_TOKENS"),
59
- ("LITELLM_NUM_RETRIES", "MN_LLM_NUM_RETRIES"),
60
- ("LITELLM_RETRY_BACKOFF_SECONDS", "MN_LLM_RETRY_BACKOFF_SECONDS"),
61
- ):
62
- if primary not in environment and legacy in environment:
63
- environment[primary] = environment[legacy]
64
-
65
-
66
- def run_mode_label(manifest: dict) -> str:
67
- is_live = manifest.get("daemon") is True or manifest.get("policies", {}).get("stream_mode") == "live"
68
- if is_live and manifest.get("daemon") is True:
69
- return "Live daemon"
70
- if is_live:
71
- return "Live"
72
- return "Batch"
@@ -1,79 +0,0 @@
1
- import json
2
-
3
- from mn_cli.libs.run_logs import JobLogWriter, materialize_sent_email_copy
4
- from mn_cli.libs.run_manifest import prepare_manifest_for_submission
5
-
6
-
7
- def test_prepare_manifest_for_submission_merges_runtime_env_and_metadata(tmp_path):
8
- bundle_dir = tmp_path / "bundle"
9
- bundle_dir.mkdir()
10
- config_dir = bundle_dir / "config"
11
- config_dir.mkdir()
12
- (config_dir / "default.json").write_text(json.dumps({"identity": {"blueprint_id": "bp"}}))
13
-
14
- manifest = {
15
- "nodes": [
16
- {
17
- "node_id": "worker",
18
- "config": {
19
- "environment": {
20
- "LITELLM_MODEL": "ollama/test",
21
- "MN_LLM_API_KEY": "kept",
22
- }
23
- },
24
- }
25
- ]
26
- }
27
-
28
- prepared = prepare_manifest_for_submission(
29
- bundle_dir,
30
- manifest,
31
- env_overrides={"MN_RUN_ID": "run-1"},
32
- submission_metadata={"blueprint_id": "bp"},
33
- )
34
-
35
- env = prepared["nodes"][0]["config"]["environment"]
36
- assert json.loads(env["MN_BLUEPRINT_CONFIG_JSON"])["identity"]["blueprint_id"] == "bp"
37
- assert env["MN_RUN_ID"] == "run-1"
38
- assert env["MN_LLM_MODEL"] == "ollama/test"
39
- assert env["MN_LLM_API_KEY"] == "kept"
40
- assert prepared["metadata"]["mn_cli"]["blueprint_id"] == "bp"
41
-
42
-
43
- def test_job_log_writer_deduplicates_events_and_records_web_ui_once():
44
- writer = JobLogWriter("unit-run-helper")
45
- event = {
46
- "timestamp": "2026-05-01T00:00:00Z",
47
- "type": "custom",
48
- "payload": {"message_id": "m1", "web_ui": {"url": "http://localhost:1"}},
49
- }
50
-
51
- assert writer.write_event(event) is True
52
- assert writer.write_event(event) is False
53
- assert writer.record_web_ui_url(event) == "http://localhost:1"
54
- assert writer.record_web_ui_url(event) is None
55
-
56
-
57
- def test_materialize_sent_email_copy_uses_safe_host_paths(tmp_path):
58
- materialize_sent_email_copy(
59
- tmp_path,
60
- {
61
- "provider_id": "id/with spaces",
62
- "sent_email_copy": {
63
- "html_path": "../unsafe.html",
64
- "text_content": "plain",
65
- "html_content": "<p>Hello</p>",
66
- "metadata": {"provider": "test"},
67
- },
68
- },
69
- )
70
-
71
- email_dir = tmp_path / "sent_emails"
72
- assert (email_dir / "unsafe.html").read_text() == "<p>Hello</p>"
73
- metadata = json.loads((email_dir / "id-with-spaces.json").read_text())
74
- assert metadata["provider"] == "test"
75
- assert PathLikeName(metadata["host_html_path"]) == "unsafe.html"
76
-
77
-
78
- def PathLikeName(path: str) -> str:
79
- return path.rsplit("/", 1)[-1]