Skip to content

Commit d3e7530

Browse files
committed
Refactor generate_exec_scripts.py: Modularize code and improve readability
- Removed extensive inline functions and replaced them with imports from new modules. - Introduced a structured approach by separating concerns into different files: - `metadata.py` for metadata generation. - `paths.py` for path-related utilities. - `script_generation.py` for script generation logic. - `validation.py` for JSON schema validation. - Updated the main execution block to utilize the new modular structure. - Improved argument parsing and handling of workspace directory. - Enhanced error handling and directory management during script generation.
1 parent 18964ce commit d3e7530

6 files changed

Lines changed: 1113 additions & 1095 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Submodules for topology validation and execution-script generation."""
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Metadata generation for created execution script sets."""
2+
3+
import json
4+
import os
5+
import shlex
6+
import sys
7+
from datetime import datetime
8+
9+
from .validation import normalize_intermediate_entries, require_positive_int
10+
11+
12+
def collect_metadata_node_names(json_content):
13+
"""Collect host names, node names, and topic counts for metadata.txt."""
14+
host_names = []
15+
publisher_names = []
16+
subscriber_names = []
17+
intermediate_names = []
18+
topic_names = set()
19+
20+
for host_dict in json_content["hosts"]:
21+
host_names.append(host_dict["host_name"])
22+
for node in host_dict.get("nodes", []):
23+
node_name = node["node_name"]
24+
if node.get("publisher"):
25+
publisher_names.append(node_name)
26+
for publisher in node["publisher"]:
27+
topic_names.add(publisher["topic_name"])
28+
if node.get("subscriber"):
29+
subscriber_names.append(node_name)
30+
for subscriber in node["subscriber"]:
31+
topic_names.add(subscriber["topic_name"])
32+
if "intermediate" in node:
33+
intermediate_names.append(node_name)
34+
intermediate_entries = normalize_intermediate_entries(
35+
node["intermediate"], node_name
36+
)
37+
for intermediate_entry in intermediate_entries:
38+
for publisher in intermediate_entry.get("publisher", []):
39+
topic_names.add(publisher["topic_name"])
40+
for subscriber in intermediate_entry.get("subscriber", []):
41+
topic_names.add(subscriber["topic_name"])
42+
43+
return host_names, publisher_names, subscriber_names, intermediate_names, topic_names
44+
45+
46+
def collect_topic_runtime_config(json_content):
47+
"""Collect topic -> payload/period/publisher_count from topology."""
48+
topic_cfg = {}
49+
50+
def add_publisher_topic(entry, context):
51+
topic = entry.get("topic_name")
52+
if not topic:
53+
raise ValueError(f"{context}: missing topic_name")
54+
payload_size = require_positive_int(entry, "payload_size", context)
55+
period_ms = require_positive_int(entry, "period_ms", context)
56+
57+
cfg = topic_cfg.setdefault(
58+
topic,
59+
{
60+
"payload_size": payload_size,
61+
"period_ms": period_ms,
62+
"publisher_count": 0,
63+
},
64+
)
65+
if cfg["payload_size"] != payload_size or cfg["period_ms"] != period_ms:
66+
raise ValueError(
67+
f"Inconsistent payload/period for topic '{topic}' in topology JSON"
68+
)
69+
cfg["publisher_count"] += 1
70+
71+
for host in json_content.get("hosts", []):
72+
for node in host.get("nodes", []):
73+
node_name = node.get("node_name", "?")
74+
for pub_idx, publisher in enumerate(node.get("publisher", []) or []):
75+
add_publisher_topic(
76+
publisher, f"node '{node_name}' publisher[{pub_idx}]"
77+
)
78+
79+
if "intermediate" in node:
80+
intermediate_entries = normalize_intermediate_entries(
81+
node["intermediate"], node_name
82+
)
83+
for entry_idx, inter in enumerate(intermediate_entries):
84+
for pub_idx, publisher in enumerate(inter.get("publisher", []) or []):
85+
add_publisher_topic(
86+
publisher,
87+
f"node '{node_name}' intermediate[{entry_idx}] publisher[{pub_idx}]",
88+
)
89+
90+
return topic_cfg
91+
92+
93+
def unique_in_order(items):
94+
"""Remove duplicates while preserving the original order."""
95+
return list(dict.fromkeys(items))
96+
97+
98+
def generate_metadata_file(
99+
json_content, json_path, rmw, ws_dir, project_root, scenario_dir
100+
):
101+
"""Generate <ws-dir>/latest/metadata.txt."""
102+
latest_dir = os.path.join(project_root, ws_dir, "latest")
103+
metadata_path = os.path.join(latest_dir, "metadata.txt")
104+
105+
(
106+
host_names,
107+
publisher_names,
108+
subscriber_names,
109+
intermediate_names,
110+
topic_names,
111+
) = collect_metadata_node_names(json_content)
112+
host_names = unique_in_order(host_names)
113+
publisher_names = unique_in_order(publisher_names)
114+
subscriber_names = unique_in_order(subscriber_names)
115+
intermediate_names = unique_in_order(intermediate_names)
116+
topic_runtime_cfg = collect_topic_runtime_config(json_content)
117+
118+
all_nodes = [
119+
node
120+
for host in json_content["hosts"]
121+
for node in host.get("nodes", [])
122+
]
123+
node_count = len(all_nodes)
124+
125+
qos = json_content.get("qos", {})
126+
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
127+
sections = [
128+
[
129+
"# --- 1. general info ---",
130+
f"command: {shlex.join(sys.argv)}",
131+
f"timestamp: {timestamp}",
132+
f"json: {os.path.basename(json_path)}",
133+
f"json_path: {json_path}",
134+
f"ws_dir: {ws_dir}",
135+
f"scenario_dir: {scenario_dir}",
136+
],
137+
[
138+
"# --- 2. test config ---",
139+
f"rmw: {rmw}",
140+
f"qos_history: {qos.get('history', 'KEEP_LAST')}",
141+
f"qos_depth: {qos.get('depth', 1)}",
142+
f"qos_reliability: {qos.get('reliability', 'RELIABLE')}",
143+
],
144+
[
145+
"# --- 3. topology stats ---",
146+
f"host_count: {len(host_names)}",
147+
f"node_count: {node_count}",
148+
f"publisher_count: {len(publisher_names)}",
149+
f"subscriber_count: {len(subscriber_names)}",
150+
f"intermediate_count: {len(intermediate_names)}",
151+
f"topic_count: {len(topic_names)}",
152+
f"hosts: {', '.join(host_names)}",
153+
f"publishers: {', '.join(publisher_names)}",
154+
f"subscribers: {', '.join(subscriber_names)}",
155+
f"intermediates: {', '.join(intermediate_names)}",
156+
f"topics: {', '.join(sorted(topic_names))}",
157+
(
158+
"topic_runtime_json: "
159+
f"{json.dumps(topic_runtime_cfg, separators=(',', ':'), sort_keys=True)}"
160+
),
161+
],
162+
]
163+
164+
with open(metadata_path, "w") as f:
165+
f.write("\n\n".join("\n".join(section) for section in sections) + "\n")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Filesystem/path operations for output generation."""
2+
3+
import os
4+
import shutil
5+
import sys
6+
7+
8+
def clear_directory_contents(path):
9+
"""Delete everything directly under the given directory."""
10+
for name in os.listdir(path):
11+
target = os.path.join(path, name)
12+
if os.path.islink(target) or os.path.isfile(target):
13+
os.remove(target)
14+
elif os.path.isdir(target):
15+
shutil.rmtree(target)
16+
17+
18+
def read_existing_json_path(run_dir):
19+
"""Return the json_path field from metadata.txt in run_dir if it exists."""
20+
metadata_path = os.path.join(run_dir, "metadata.txt")
21+
if not os.path.isfile(metadata_path):
22+
return None
23+
with open(metadata_path) as f:
24+
for line in f:
25+
if line.startswith("json_path:"):
26+
return os.path.normpath(line.split(":", 1)[1].strip())
27+
return None
28+
29+
30+
def confirm_overwrite(output_dir, force=False, existing_json_path=None, new_json_path=None):
31+
"""Ask whether an existing exec_scripts directory should be overwritten."""
32+
if force:
33+
return True
34+
if not sys.stdin.isatty():
35+
raise SystemExit(
36+
f"Error: '{output_dir}' already exists and stdin is not a TTY. "
37+
"Use --force (-f) to overwrite without confirmation."
38+
)
39+
msg = f"'{output_dir}' already exists."
40+
if (
41+
existing_json_path is not None
42+
and new_json_path is not None
43+
):
44+
existing_normalized = os.path.normpath(existing_json_path)
45+
new_normalized = os.path.normpath(new_json_path)
46+
if existing_normalized != new_normalized:
47+
msg += (
48+
f"\n WARNING: The existing scripts were generated from '{existing_normalized}',"
49+
f"\n but the current input is '{new_normalized}'."
50+
f"\n Same filename, different path -- are you sure you want to overwrite?"
51+
)
52+
msg += " Overwrite generated files? [y/N]: "
53+
while True:
54+
answer = input(msg).strip().lower()
55+
if answer in ("y", "yes"):
56+
return True
57+
if answer in ("", "n", "no"):
58+
return False
59+
print("Please answer yes or no.")
60+
61+
62+
def update_latest_symlink(base_dir, target_name):
63+
"""Update <ws-dir>/latest to point to target_name."""
64+
latest_link = os.path.join(base_dir, "latest")
65+
if os.path.lexists(latest_link):
66+
if os.path.islink(latest_link) or os.path.isfile(latest_link):
67+
os.remove(latest_link)
68+
elif os.path.isdir(latest_link):
69+
shutil.rmtree(latest_link)
70+
os.symlink(target_name, latest_link)
71+
72+
73+
def resolve_output_paths(json_path, rmw, ws_dir, force=False):
74+
"""Resolve and prepare output directory paths and the latest alias."""
75+
project_root = os.getcwd()
76+
perf_ws_dir = os.path.join(project_root, ws_dir)
77+
os.makedirs(perf_ws_dir, exist_ok=True)
78+
79+
json_basename = os.path.splitext(os.path.basename(json_path))[0]
80+
scenario_dir = f"{json_basename}-{rmw}"
81+
run_dir = os.path.join(perf_ws_dir, scenario_dir)
82+
output_dir = os.path.join(run_dir, "exec_scripts")
83+
84+
overwrite = os.path.isdir(output_dir)
85+
if overwrite:
86+
existing_json_path = read_existing_json_path(run_dir)
87+
if not confirm_overwrite(
88+
output_dir,
89+
force=force,
90+
existing_json_path=existing_json_path,
91+
new_json_path=json_path,
92+
):
93+
raise SystemExit("Canceled by user. No files were generated.")
94+
95+
return project_root, output_dir, scenario_dir, overwrite

0 commit comments

Comments
 (0)