wolfBoot/tools/scripts/config2presets.py

645 lines
20 KiB
Python

#!/usr/bin/env python3
# Adds .config file to CMakePresets.json
#
# Example:
# python3 ./tools/scripts/config2presets.py ./config/examples/stm32h7.config
import argparse
import json
import os
import re
import sys
import subprocess
from collections import OrderedDict
from pathlib import Path
COMMENT_RE = re.compile(r"\s*(#.*)?$")
LINE_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\??=\s*(.*)$')
BOOL_TRUE = {"1", "on", "true", "yes", "y"}
BOOL_FALSE = {"0", "off", "false", "no", "n"}
# Known inherited values: these are provided implicitly by presets/build
# If the .config value matches, do NOT write to cacheVariables.
# If it differs, keep it and warn that it overrides the inherited default.
KNOWN_INHERITED = {
"WOLFBOOT_CONFIG_MODE": "preset",
"CMAKE_TOOLCHAIN_FILE": "cmake/toolchain_arm-none-eabi.cmake",
"SIGN": "ECC256",
"HASH": "SHA256",
"BUILD_TEST_APPS": "ON"
}
def normalize_bool(s: str):
v = s.strip().lower()
if v in BOOL_TRUE:
return "ON"
if v in BOOL_FALSE:
return "OFF"
return None
def parse_config(path: Path):
kv = OrderedDict()
with path.open("r", encoding="utf-8") as f:
for ln in f:
if COMMENT_RE.fullmatch(ln):
continue
m = LINE_RE.match(ln.rstrip("\n"))
if not m:
# skip silently; load_dot_config warns elsewhere
continue
key, val = m.group(1), m.group(2).strip()
kv[key] = val
return kv
def filter_inherited_values(kv):
"""
Remove keys whose values match KNOWN_INHERITED defaults (do not
emit them into cacheVariables), and collect messages about
inherited/overridden values.
"""
new_kv = OrderedDict()
messages = []
for k, v in kv.items():
if k in KNOWN_INHERITED:
expected = KNOWN_INHERITED[k]
v_stripped = v.strip()
if v_stripped == expected:
messages.append(
"Note: '{}' is an inherited value with default '{}'; "
"omitting from preset cacheVariables.".format(k, expected)
)
# Do not add to new_kv; rely on inherited value
continue
else:
messages.append(
"WARNING: '{}' in .config is '{}', overriding inherited "
"default '{}' in preset cacheVariables.".format(
k, v_stripped, expected
)
)
# Fall through to keep the override
new_kv[k] = v
return new_kv, messages
def choose_target(kv):
# Prefer explicit wolfBoot var; else accept TARGET if present
if "WOLFBOOT_TARGET" in kv and kv["WOLFBOOT_TARGET"]:
return kv["WOLFBOOT_TARGET"]
if "TARGET" in kv and kv["TARGET"]:
return kv["TARGET"]
return "custom"
def to_cache_vars(kv):
cache = OrderedDict()
for k, v in kv.items():
# Map TARGET -> WOLFBOOT_TARGET if the latter is not already set
if k == "TARGET" and "WOLFBOOT_TARGET" not in kv:
cache["WOLFBOOT_TARGET"] = v
continue
# Normalize booleans to ON/OFF; keep everything else as strings
nb = normalize_bool(v)
cache[k] = nb if nb is not None else v
return cache
def ensure_base_vars(cache, toolchain_value):
# These should be inherited from [base] in the JSON presets:
# Always ensure toolchain file is set using the literal value passed in
# cache.setdefault("CMAKE_TOOLCHAIN_FILE", toolchain_value)
# Typically desired
# cache.setdefault("BUILD_TEST_APPS", "ON")
# Force preset mode when generating from .config into presets
# cache["WOLFBOOT_CONFIG_MODE"] = "preset"
return cache
def make_preset_name(target):
return f"{target}"
def make_binary_dir(source_dir, target):
return os.path.join(source_dir, f"build-{target}")
def validate_presets_json(presets_path: Path):
"""
If the presets file exists but is not valid JSON, print a clear message
and exit with a non-zero status instead of raising an exception.
"""
if not presets_path.exists():
return
try:
with presets_path.open("r", encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as e:
print(
"Error: '{}' exists but is not valid JSON (CMakePresets.json is malformed).".format(
presets_path
),
file=sys.stderr,
)
print("Details: {}".format(e), file=sys.stderr)
print(
"Please fix the JSON (for example, remove any trailing commas) and rerun this script.",
file=sys.stderr,
)
sys.exit(3)
def load_existing_presets(presets_path: Path):
try:
with presets_path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return None
def _merge_configure_preset_list(preset_list, cfg_preset):
"""
Update or insert a configure preset.
- If a preset with the same name exists:
* Preserve all top-level keys (inherits, environment, binaryDir, etc.)
* Merge cacheVariables: existing keys plus new ones, with new values winning.
- If it does not exist:
* Insert the new preset among the non-hidden presets in a best-fit
alphabetical position, ignoring hidden presets when choosing where
to insert. Existing entries (hidden or not) are not reordered.
"""
name = cfg_preset.get("name")
# First: update existing entry if present
for idx, existing in enumerate(preset_list):
if existing.get("name") == name:
merged = existing.copy()
existing_cache = existing.get("cacheVariables", {})
new_cache = cfg_preset.get("cacheVariables", {})
merged_cache = {}
if isinstance(existing_cache, dict):
merged_cache.update(existing_cache)
if isinstance(new_cache, dict):
merged_cache.update(new_cache)
merged["cacheVariables"] = merged_cache
merged["name"] = name
preset_list[idx] = merged
return preset_list
# Only reach here if this is a new preset
if not isinstance(name, str):
# Fallback: no sensible name to compare; just append
preset_list.append(cfg_preset)
return preset_list
# Build a list of (real_index, preset_name) for NON-hidden presets
visible = []
for idx, existing in enumerate(preset_list):
# Treat missing "hidden" as visible
if existing.get("hidden") is True:
continue
existing_name = existing.get("name")
if isinstance(existing_name, str):
visible.append((idx, existing_name))
# Decide insertion point among visible presets
insert_at_real_index = None
for real_idx, existing_name in visible:
if name < existing_name:
insert_at_real_index = real_idx
break
if insert_at_real_index is None:
if visible:
# After the last visible preset
last_visible_index = visible[-1][0]
insert_at_real_index = last_visible_index + 1
else:
# No visible presets at all: append
insert_at_real_index = len(preset_list)
preset_list.insert(insert_at_real_index, cfg_preset)
return preset_list
def _merge_build_preset_list(preset_list, bld_preset):
"""
Update or insert a build preset, keeping existing entries in their
current order and placing new ones in a best-fit alphabetical position.
- If a preset with the same name exists:
* Preserve existing jobs/verbose/targets and any other fields.
* Only ensure configurePreset is set if it was missing.
- If it does not exist:
* Consider only entries whose configurePreset is NOT 'sim' when
choosing the insertion point.
* Insert the new preset before the first such entry whose name is
lexicographically greater; if none, insert after the last such entry.
"""
name = bld_preset.get("name")
# First: update existing entry if present
for idx, existing in enumerate(preset_list):
if existing.get("name") == name:
merged = existing.copy()
if "configurePreset" not in merged and "configurePreset" in bld_preset:
merged["configurePreset"] = bld_preset["configurePreset"]
merged["name"] = name
preset_list[idx] = merged
return preset_list
# Only reach here for a new preset
if not isinstance(name, str):
# No reasonable name to compare; just append
preset_list.append(bld_preset)
return preset_list
# Build list of (real_index, preset_name) for entries we care about
# Ignore any entry whose configurePreset is 'sim'.
visible = []
for idx, existing in enumerate(preset_list):
if existing.get("configurePreset") == "sim":
continue
existing_name = existing.get("name")
if isinstance(existing_name, str):
visible.append((idx, existing_name))
insert_at_real_index = None
# Find first visible preset whose name is lexicographically greater
for real_idx, existing_name in visible:
if name < existing_name:
insert_at_real_index = real_idx
break
if insert_at_real_index is None:
if visible:
# After the last visible (non-sim) preset
last_visible_index = visible[-1][0]
insert_at_real_index = last_visible_index + 1
else:
# No visible presets at all: append to the end
insert_at_real_index = len(preset_list)
preset_list.insert(insert_at_real_index, bld_preset)
return preset_list
def merge_preset(doc, cfg_preset, bld_preset):
"""
Merge a configure/build preset into an existing CMakePresets.json document.
- If doc is None, create a fresh schema v3 doc with just these presets.
- Otherwise:
* Ensure configurePresets/buildPresets arrays exist.
* Update or append the specific presets without reordering any others.
"""
if doc is None:
return {
"version": 3,
"configurePresets": [cfg_preset],
"buildPresets": [bld_preset],
}
if "configurePresets" not in doc or not isinstance(doc["configurePresets"], list):
doc["configurePresets"] = []
if "buildPresets" not in doc or not isinstance(doc["buildPresets"], list):
doc["buildPresets"] = []
_merge_configure_preset_list(doc["configurePresets"], cfg_preset)
_merge_build_preset_list(doc["buildPresets"], bld_preset)
return doc
def extract_unused_vars(output: str):
"""
Parse CMake output and return a list of
'Manually-specified variables were not used by the project' names.
"""
lines = output.splitlines()
unused = []
capture = False
for line in lines:
if "Manually-specified variables were not used by the project:" in line:
capture = True
continue
if not capture:
continue
stripped = line.strip()
# Skip leading blank lines after the header
if stripped == "":
# If we have already collected some variables, a blank line
# means the list is over.
if unused:
break
continue
# Only take reasonably-indented lines as variable names
if line.startswith(" ") or line.startswith(" "):
unused.append(stripped)
else:
# Non-indented line once capture has started means the block is over
if unused:
break
return unused
def run_cmake_and_report_unused(preset_name: str, repo_root: Path):
"""
Run 'cmake --preset <preset_name>' from repo_root and
print any unused manually-specified variables reported.
"""
print("")
print("Running CMake configure to check for unused manually-specified variables...")
cmd = ["cmake", "--preset", preset_name]
print("Command:", " ".join(cmd))
try:
proc = subprocess.run(
cmd,
cwd=str(repo_root),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except FileNotFoundError:
print("cmake not found on PATH; skipping unused-variable check.", file=sys.stderr)
return
combined = ""
if proc.stdout:
combined += proc.stdout
if proc.stderr:
if combined:
combined += "\n"
combined += proc.stderr
# If CMake failed, show the full output first so the error is visible.
if proc.returncode != 0:
print("")
print("CMake configure for preset '{}' failed with exit code {}.".format(
preset_name, proc.returncode
))
print("Full CMake output follows:")
print("==============================================")
print("============= BEGIN CMAKE OUTPUT =============")
print(combined)
print("============= END CMAKE OUTPUT =============")
print("==============================================")
unused = extract_unused_vars(combined)
if unused:
print("")
print(
"CMake reported unused manually-specified variables for preset '{}' :".format(
preset_name
)
)
for name in unused:
print(" {}".format(name))
else:
print("")
print(
"No unused manually-specified CMake variables detected for preset '{}'.".format(
preset_name
)
)
if proc.returncode != 0:
print("")
print("Note: 'cmake --preset {}' exited with code {}.".format(
preset_name, proc.returncode
))
def check_workflow_for_preset(workflow_path: Path, preset_name: str):
"""
Best-effort check: does the given workflow YAML mention this preset/target
name anywhere? If not, print a warning so it is clear the CI matrix will
not build it.
"""
if not workflow_path.exists():
print(
"Note: workflow YAML '{}' not found; skipping workflow check.".format(
workflow_path
)
)
return
try:
text = workflow_path.read_text(encoding="utf-8")
except OSError as e:
print(
"Warning: could not read workflow YAML '{}': {}".format(
workflow_path, e
),
file=sys.stderr,
)
return
if preset_name in text:
print(
"Workflow check: preset/target '{}' is referenced in '{}'.".format(
preset_name, workflow_path
)
)
else:
print(
"WARNING: preset/target '{}' was NOT found in workflow '{}'. "
"CI will not build this target until you add it to the matrix.".format(
preset_name, workflow_path
),
file=sys.stderr,
)
def main():
ap = argparse.ArgumentParser(description="Generate or merge a CMakePresets.json from a .config file")
ap.add_argument(
"config",
help="Path to .config (relative to your current directory if not absolute)",
)
ap.add_argument(
"--toolchain",
default="cmake/toolchain_arm-none-eabi.cmake",
help="Path to toolchain file as it should appear in CMAKE_TOOLCHAIN_FILE",
)
ap.add_argument(
"--presets",
default="CMakePresets.json",
help="Path to CMakePresets.json to create/merge (relative to repo root if not absolute)",
)
ap.add_argument(
"--generator",
default="Ninja",
help="CMake generator",
)
ap.add_argument(
"--preset-name",
default=None,
help="Override preset name",
)
ap.add_argument(
"--binary-dir",
default=None,
help="Override binaryDir",
)
ap.add_argument(
"--display-name",
default=None,
help="Override displayName",
)
ap.add_argument(
"--workflow",
default=".github/workflows/test-build-cmake-presets.yml",
help="Optional GitHub Actions workflow YAML to check for this preset name",
)
args = ap.parse_args()
# Begin common dir init, for /tools/scripts
script_path = Path(__file__).resolve()
script_dir = script_path.parent.resolve()
# repo root is parent of tools/scripts -- go up two levels
repo_root = (script_dir / ".." / "..").resolve()
caller_cwd = Path.cwd().resolve()
# Print only if caller's current working directory is neither REPO_ROOT nor REPO_ROOT/tools/scripts
if caller_cwd != repo_root and caller_cwd != (repo_root / "tools" / "scripts"):
print("Script paths:")
print(f"-- SCRIPT_PATH = {script_path}")
print(f"-- SCRIPT_DIR = {script_dir}")
print(f"-- REPO_ROOT = {repo_root}")
# Always work from the repo root, regardless of where the script was invoked
try:
os.chdir(repo_root)
except OSError as e:
print(f"Failed to cd to: {repo_root}\n{e}", file=sys.stderr)
sys.exit(1)
print(f"Starting {script_path} from {Path.cwd().resolve()}")
# End common dir init
# Resolve paths:
# - config: relative to caller's CWD (so user can pass local relative paths naturally)
# - presets: relative to repo root (we already chdir there)
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = (caller_cwd / config_path).resolve()
presets_path = Path(args.presets)
if not presets_path.is_absolute():
presets_path = (repo_root / presets_path).resolve()
# Pre-validate the existing presets JSON so we can show a clear message
# instead of crashing on a JSONDecodeError later.
validate_presets_json(presets_path)
kv = parse_config(config_path)
if not kv:
print(f"No settings parsed from .config: {config_path}", file=sys.stderr)
sys.exit(2)
# Handle inherited values like SIGN/HASH before converting to cache vars
kv, inherited_messages = filter_inherited_values(kv)
target = choose_target(kv)
cache = to_cache_vars(kv)
# Use the toolchain value exactly as passed on the command line,
# but normalize backslashes for JSON/CMake friendliness.
toolchain_value = args.toolchain.replace("\\", "/")
cache = ensure_base_vars(cache, toolchain_value)
# Build preset objects
source_dir = "${sourceDir}" # CMake variable; leave literal
preset_name = args.preset_name or make_preset_name(target)
binary_dir = args.binary_dir or make_binary_dir(source_dir, target)
display_name = args.display_name or f"{target}"
# For STM32 boards, inherit like stm32g0:
# [ "base", "stm32", "sign_hash_config" ]
# For everything else, just inherit "base".
inherits_value = "base"
if isinstance(preset_name, str) and preset_name.startswith("stm32") and preset_name != "stm32":
inherits_value = ["base", "stm32", "sign_hash_config"]
cfg_preset = OrderedDict(
[
("name", preset_name),
("displayName", display_name),
("inherits", inherits_value),
("generator", args.generator),
("binaryDir", binary_dir),
("cacheVariables", cache),
]
)
bld_preset = OrderedDict(
[
("name", preset_name),
("configurePreset", preset_name),
("jobs", 4),
("verbose", True),
("targets", ["all"]),
]
)
# Ensure schema v3 unless existing file says otherwise
doc = load_existing_presets(presets_path)
if doc is None:
doc = {"version": 3}
if "version" not in doc:
doc["version"] = 3
result = merge_preset(doc, cfg_preset, bld_preset)
# Pretty-print with stable key order
with presets_path.open("w", encoding="utf-8") as f:
json.dump(result, f, indent=2)
f.write("\n")
print(f"Updated {presets_path} with preset '{preset_name}' targeting '{target}'")
# Best-effort: check if this preset/target name appears in the workflow matrix
workflow_path = Path(args.workflow)
if not workflow_path.is_absolute():
workflow_path = (repo_root / workflow_path).resolve()
check_workflow_for_preset(workflow_path, preset_name)
if inherited_messages:
print("")
print("Inherited .config values summary:")
for msg in inherited_messages:
print(" " + msg)
# After updating presets, run cmake and show any unused variables
run_cmake_and_report_unused(preset_name, repo_root)
if __name__ == "__main__":
main()