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.
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/PKG-INFO +1 -1
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/PKG-INFO +1 -1
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_cmds.py +114 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/run_cmds.py +2 -0
- mirrorneuron_cli-1.1.4/mn_cli/libs/run_manifest.py +253 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_run_cmds.py +6 -2
- mirrorneuron_cli-1.1.4/tests/test_run_helpers.py +155 -0
- mirrorneuron_cli-1.1.3/mn_cli/libs/run_manifest.py +0 -72
- mirrorneuron_cli-1.1.3/tests/test_run_helpers.py +0 -79
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.github/workflows/ci.yml +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.github/workflows/release.yml +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/.gitignore +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/LICENSE +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/README.md +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/RELEASE.md +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/SOURCES.txt +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/requires.txt +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/__init__.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/config.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/error_handler.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/__init__.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_observability.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_repository.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/job_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/run_logs.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/sys_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/libs/ui.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/logging_config.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/main.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/server_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/shared.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mn_cli/update_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/pyproject.toml +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/check-release-artifacts.sh +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/make-release-zip.sh +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/scripts/validate-version-tag.sh +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/setup.cfg +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/conftest.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_blueprint_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_blueprint_repository.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_job_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_server_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_sys_cmds.py +0 -0
- {mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/tests/test_update_cmds.py +0 -0
|
@@ -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
|
-
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{mirrorneuron_cli-1.1.3 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|