"""Implements the functionalities behind the 'ceedling' command"""
import filecmp
import pickle
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import get_args
from yaml import YAMLError, safe_load
from ..helpers.click_helpers import recho, secho
from ..helpers.host_platform import get_platform
from ..helpers.logger import logger
from ..helpers.misc import (
PROJECT_ROOT,
get_multiple_files_hash_str,
terminal_link_print,
)
from ..helpers.spr import SubprocessResult, run_process
from .embedded_ut_constants import (
UNIT_TEST_BUILD_DIR_APP,
UNIT_TEST_BUILD_DIR_BL,
EmbeddedUnitTestVariants,
)
_CEEDLING_PROJECT_FILE_SRC_APP = PROJECT_ROOT / "conf/unit/app_project_<platform>.yml"
_CEEDLING_PROJECT_FILE_SRC_BL = (
PROJECT_ROOT / "conf/unit/bootloader_project_<platform>.yml"
)
_HALCOGEN_REMOVE_FILE = PROJECT_ROOT / "conf/hcg/<project>-remove.yml"
CEEDLING_PROJECT_FILE_TGT_APP = UNIT_TEST_BUILD_DIR_APP / "project.yml"
CEEDLING_PROJECT_FILE_TGT_BL = UNIT_TEST_BUILD_DIR_BL / "project.yml"
HALCOGEN_HCG_SRC_APP = PROJECT_ROOT / "conf/hcg/app.hcg"
HALCOGEN_HCG_SRC_BL = PROJECT_ROOT / "conf/hcg/bootloader.hcg"
HALCOGEN_HCG_TGT_APP = UNIT_TEST_BUILD_DIR_APP / "app.hcg"
HALCOGEN_HCG_TGT_BL = UNIT_TEST_BUILD_DIR_BL / "bootloader.hcg"
HALCOGEN_DIL_SRC_APP = PROJECT_ROOT / "conf/hcg/app.dil"
HALCOGEN_DIL_SRC_BL = PROJECT_ROOT / "conf/hcg/bootloader.dil"
HALCOGEN_DIL_TGT_APP = UNIT_TEST_BUILD_DIR_APP / "app.dil"
HALCOGEN_DIL_TGT_BL = UNIT_TEST_BUILD_DIR_BL / "bootloader.dil"
if get_platform() == "linux":
CEEDLING_PROJECT_FILE_SRC_APP = Path(
str(_CEEDLING_PROJECT_FILE_SRC_APP).replace("<platform>", "posix")
)
CEEDLING_PROJECT_FILE_SRC_BL = Path(
str(_CEEDLING_PROJECT_FILE_SRC_BL).replace("<platform>", "posix")
)
elif get_platform() == "win32":
CEEDLING_PROJECT_FILE_SRC_APP = Path(
str(_CEEDLING_PROJECT_FILE_SRC_APP).replace("<platform>", "win32")
)
CEEDLING_PROJECT_FILE_SRC_BL = Path(
str(_CEEDLING_PROJECT_FILE_SRC_BL).replace("<platform>", "win32")
)
else:
ERR = f"Something went wrong in '{Path(__file__).absolute()}'"
raise SystemExit(ERR)
@dataclass
class SourceTargetPair:
"""Source-Target"""
src: Path
tgt: Path
SOURCE_TARGET_PAIRS = {
"app": [
SourceTargetPair(CEEDLING_PROJECT_FILE_SRC_APP, CEEDLING_PROJECT_FILE_TGT_APP),
SourceTargetPair(HALCOGEN_HCG_SRC_APP, HALCOGEN_HCG_TGT_APP),
SourceTargetPair(HALCOGEN_DIL_SRC_APP, HALCOGEN_DIL_TGT_APP),
],
"bootloader": [
SourceTargetPair(CEEDLING_PROJECT_FILE_SRC_BL, CEEDLING_PROJECT_FILE_TGT_BL),
SourceTargetPair(HALCOGEN_HCG_SRC_BL, HALCOGEN_HCG_TGT_BL),
SourceTargetPair(HALCOGEN_DIL_SRC_BL, HALCOGEN_DIL_TGT_BL),
],
}
@dataclass
class HcgRunData:
"""Data required to run the TI HALCoGen code generator in the foxBMS
required way
"""
base_dir: Path
input_hcg: Path
input_dil: Path
output_dil: Path
hash_pickle: Path
remove_file: Path
def _get_remove_cfg(file: Path) -> list[str]:
try:
txt = file.read_text(encoding="utf-8")
except (
FileNotFoundError,
IsADirectoryError,
PermissionError,
UnicodeDecodeError,
OSError,
TypeError,
) as exc:
err = "Could not load remove configuration file."
raise SystemExit(err) from exc
try:
remove_cfg: list[str] = safe_load(txt)
except YAMLError as exc:
err = "Could not load remove configuration."
raise SystemExit(err) from exc
return remove_cfg
def run_embedded_tests(
args: list[str] | None = None,
project: EmbeddedUnitTestVariants = "app",
stderr: int | None = None,
stdout: int | None = None,
) -> SubprocessResult:
"""Run the embedded sources tests"""
secho(f"Testing project: '{project}'")
secho("Running 'ceedling' directly: all arguments are passed to ceedling verbatim.")
if project == "app":
base_dir = UNIT_TEST_BUILD_DIR_APP
input_hcg = HALCOGEN_HCG_TGT_APP
input_dil = HALCOGEN_DIL_SRC_APP
output_dil = HALCOGEN_DIL_TGT_APP
elif project == "bootloader":
base_dir = UNIT_TEST_BUILD_DIR_BL
input_hcg = HALCOGEN_HCG_TGT_BL
input_dil = HALCOGEN_DIL_SRC_BL
output_dil = HALCOGEN_DIL_TGT_BL
else:
err = (
"Something went wrong.\nExpect argument from list "
f"{get_args(EmbeddedUnitTestVariants)}, but got '{project}'."
)
raise SystemExit(err)
remove_file = Path(str(_HALCOGEN_REMOVE_FILE).replace("<project>", project))
if not args:
args = ["help"]
logger.debug("arguments to ceedling are: %s", args)
_make_unit_test_dir(project)
hcg_needs_to_run = _copy_config_if_needed(project)
if args == ["help"]:
return _run_ceedling(args, cwd=base_dir, stderr=stderr, stdout=stdout)
hcg_files_hash_pickle = base_dir / "manual_runner.pickle"
if hcg_files_hash_pickle.is_file():
with open(hcg_files_hash_pickle, "rb") as f:
try:
old_hcg_files_hash: str = pickle.load(f)
except AttributeError:
old_hcg_files_hash = ""
hcg_needs_to_run = True
else:
old_hcg_files_hash = ""
hcg_needs_to_run = True
files = (
list(base_dir.glob("source/*.c"))
+ list(base_dir.glob("include/*.h"))
+ [remove_file]
)
current_hcg_files_hash = get_multiple_files_hash_str(files)
if old_hcg_files_hash != current_hcg_files_hash:
hcg_needs_to_run = True
if hcg_needs_to_run:
secho("HALCoGen needs to run.")
tmp = HcgRunData(
base_dir,
input_hcg,
input_dil,
output_dil,
hcg_files_hash_pickle,
remove_file,
)
_run_halcogen(tmp)
err = _run_ceedling(args, cwd=base_dir, stderr=stderr, stdout=stdout)
uses_coverage = any(True for i in args if i.startswith("gcov"))
_print_result(project, base_dir, err, uses_coverage)
return err
def _cleanup_hcg_sources(base_dir: Path, remove_file: Path) -> None:
remove = _get_remove_cfg(remove_file)
freertos_config_file = base_dir / "include/FreeRTOSConfig.h"
if freertos_config_file.is_file():
frequency = None
for line in freertos_config_file.read_text(encoding="utf-8").splitlines():
mach = re.search(
r"#define configCPU_CLOCK_HZ.*\( \( unsigned portLONG \) ([0-9]+) \)",
line,
)
if mach:
frequency = mach.group(1)
break
if not frequency:
err = "Could not determine clock frequency."
raise SystemExit(err)
tmp = base_dir / "include/config_cpu_clock_hz.h"
define_guard = tmp.name.upper().replace(".H", "_H_")
tmp.write_text(
f"#ifndef {define_guard}\n"
f"#define {define_guard}\n"
f"#define HALCOGEN_CPU_CLOCK_HZ ({frequency})\n"
f"#endif /* {define_guard} */\n",
encoding="utf-8",
)
for i in remove:
(base_dir / i).unlink()
def _make_unit_test_dir(project: EmbeddedUnitTestVariants = "app") -> None:
"""Create the unit test directory, if it does not exist"""
if project == "app":
base = UNIT_TEST_BUILD_DIR_APP
elif project == "bootloader":
base = UNIT_TEST_BUILD_DIR_BL
else:
err = (
"Something went wrong.\nExpect argument from list "
f"{get_args(EmbeddedUnitTestVariants)}, but got '{project}'."
)
raise SystemExit(err)
for i in (base, (base / "include")):
i.mkdir(parents=True, exist_ok=True)
def _print_result(
project: EmbeddedUnitTestVariants,
base_dir: Path,
err: SubprocessResult,
uses_coverage: bool = False,
) -> None:
report_link = base_dir / "artifacts/gcov/gcovr/GcovCoverageResults.html"
if not err.returncode:
secho(f"\nThe {project} unit tests were successful.", fg="green")
else:
recho(f"The {project} unit tests were not successful.")
if all((report_link.is_file(), not err.returncode, uses_coverage)):
secho(f"\ncoverage report: {terminal_link_print(report_link)}", fg="green")
def _copy_config_if_needed(project: EmbeddedUnitTestVariants) -> bool:
hcg_needs_to_run = False
for i in SOURCE_TARGET_PAIRS[project]:
if not Path(i.tgt).is_file() or not filecmp.cmp(i.src, i.tgt):
shutil.copyfile(i.src, i.tgt)
hcg_needs_to_run = True
return hcg_needs_to_run
def _run_halcogen(args: HcgRunData) -> None:
halcogen = shutil.which("halcogen")
if not halcogen:
recho("Could not find program 'HALCogen'.")
recho("Assuming HALCoGen sources are available...", fg="yellow")
return
cmd = [halcogen, "-i", str(args.input_hcg)]
cwd = args.base_dir
ret = run_process(cmd, cwd=cwd, stderr=None, stdout=None)
if ret.returncode:
err = "Could not run HALCoGen."
raise SystemExit(err)
_cleanup_hcg_sources(args.base_dir, args.remove_file)
current_hcg_files_hash = get_multiple_files_hash_str(
list(args.base_dir.glob("source/*.c"))
+ list(args.base_dir.glob("include/*.h"))
+ [args.remove_file]
)
with open(args.hash_pickle, "wb") as f:
pickle.dump(current_hcg_files_hash, f)
shutil.copyfile(args.input_dil, args.output_dil)
def _run_ceedling(
args: list[str],
cwd: Path = UNIT_TEST_BUILD_DIR_APP,
stderr: int | None = None,
stdout: int | None = None,
) -> SubprocessResult:
"""Runs Ceedling with the provided arguments"""
ruby = shutil.which("ruby")
if not ruby:
recho("Could not find program 'ruby'.")
return SubprocessResult(1)
ceedling = shutil.which("ceedling")
if not ceedling:
recho("Could not find program 'ceedling'.")
return SubprocessResult(1)
cmd = [ceedling] + args
return run_process(cmd, cwd=cwd, stderr=stderr, stdout=stdout)