322 lines
12 KiB
Python
322 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import contextlib
|
|
import fnmatch
|
|
import io
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shlex
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = ROOT / "scripts" / "deduplicate_full_build_flags.py"
|
|
BUILD_SCRIPT = ROOT / "build_legacy.sh"
|
|
|
|
|
|
class FakeEnvironment:
|
|
def __init__(self, flags):
|
|
self.values = {"BUILD_FLAGS": flags}
|
|
self.replacements = []
|
|
self.middlewares = []
|
|
|
|
def get(self, name, default=None):
|
|
return self.values.get(name, default)
|
|
|
|
def Replace(self, **values):
|
|
self.values.update(values)
|
|
self.replacements.append(values)
|
|
|
|
def AddBuildMiddleware(self, callback, pattern=None):
|
|
self.middlewares.append((callback, pattern))
|
|
|
|
|
|
def flatten(values):
|
|
return [token for value in values for token in shlex.split(str(value))]
|
|
|
|
|
|
def apply_policy(flags, overlay, *, full=True, companion=True, logging=False):
|
|
environment = FakeEnvironment(flags)
|
|
output = io.StringIO()
|
|
variables = {
|
|
"MESHCORE_ESP32_FULL_BUILD": "1" if full else "0",
|
|
"MESHCORE_COMPANION_RADIO_FULL": "1" if companion else "0",
|
|
"MESHCORE_REQUIRE_PACKET_LOGGING": "1" if logging else "0",
|
|
"PLATFORMIO_BUILD_FLAGS": overlay,
|
|
}
|
|
with mock.patch.dict(os.environ, variables, clear=False):
|
|
with contextlib.redirect_stdout(output):
|
|
namespace = runpy.run_path(
|
|
str(SCRIPT),
|
|
init_globals={
|
|
"Import": lambda _name: None,
|
|
"env": environment,
|
|
},
|
|
)
|
|
return environment, output.getvalue(), namespace
|
|
|
|
|
|
class FullBuildFlagDedupTest(unittest.TestCase):
|
|
def test_required_packet_logging_removes_all_conflicting_forms(self):
|
|
for disable in ["-UMESH_PACKET_LOGGING", "-U MESH_PACKET_LOGGING",
|
|
"-DMESH_PACKET_LOGGING=0", "-D MESH_PACKET_LOGGING=0"]:
|
|
for before in (True, False):
|
|
with self.subTest(disable=disable, before=before):
|
|
enable = "-DMESH_PACKET_LOGGING=1"
|
|
flags = [disable, enable] if before else [enable, disable]
|
|
env, _, _ = apply_policy(flags, enable, logging=True)
|
|
self.assertEqual(flatten(env.values["BUILD_FLAGS"]), [enable])
|
|
|
|
def test_required_logging_preserves_other_macros_and_quoted_inputs(self):
|
|
flags = ["-UMESH_DEBUG -D MESH_PACKET_LOGGING_COMPACT=0 "
|
|
"-I 'include path' -include 'forced header.h' "
|
|
"-D LABEL='\"USB logging Full\"' -U MESH_PACKET_LOGGING"]
|
|
env, _, ns = apply_policy(flags, "", logging=True)
|
|
actual = ns["logical_flags"](env.values["BUILD_FLAGS"])
|
|
expected = ns["logical_flags"](flags)[:-1] + [("-DMESH_PACKET_LOGGING=1",)]
|
|
self.assertEqual(actual, expected)
|
|
|
|
def test_without_logging_contract_explicit_disables_remain(self):
|
|
flags = ["-UMESH_PACKET_LOGGING -DMESH_PACKET_LOGGING=0"]
|
|
env, _, _ = apply_policy(flags, "", logging=False)
|
|
self.assertEqual(env.values["BUILD_FLAGS"], flags)
|
|
# A stale external variable cannot turn a standard image into Full.
|
|
env, _, _ = apply_policy(flags, "", full=False, logging=True)
|
|
self.assertEqual(env.values["BUILD_FLAGS"], flags)
|
|
|
|
def test_repeated_extends_overlay_keeps_last_exact_occurrence(self):
|
|
overlay = (
|
|
"-DFEATURE=1 -UFEATURE -DFEATURE=1 "
|
|
"-D LABEL='\"Full build\"' -fno-exceptions"
|
|
)
|
|
flags = [
|
|
"-DBOARD=1 -Wextra " + overlay,
|
|
"-DWITH_MQTT=1 -Wextra " + overlay,
|
|
"-DCHILD_PROFILE=1 " + overlay,
|
|
]
|
|
|
|
environment, output, namespace = apply_policy(flags, overlay)
|
|
result = flatten(environment.values["BUILD_FLAGS"])
|
|
logical = namespace["logical_flags"](
|
|
environment.values["BUILD_FLAGS"]
|
|
)
|
|
|
|
self.assertEqual(result.count("-DFEATURE=1"), 2)
|
|
self.assertEqual(result.count("-UFEATURE"), 1)
|
|
macro_sequence = [
|
|
token
|
|
for token in result
|
|
if token in {"-DFEATURE=1", "-UFEATURE"}
|
|
]
|
|
self.assertEqual(
|
|
macro_sequence,
|
|
["-DFEATURE=1", "-UFEATURE", "-DFEATURE=1"],
|
|
)
|
|
self.assertEqual(logical.count(("-D", 'LABEL="Full build"')), 1)
|
|
self.assertEqual(result.count("-Wextra"), 2)
|
|
self.assertIn("-DBOARD=1", result)
|
|
self.assertIn("-DWITH_MQTT=1", result)
|
|
self.assertIn("-DCHILD_PROFILE=1", result)
|
|
# -fno-exceptions is not de-duplicated: unlike -D/-U, arbitrary
|
|
# compiler/linker options can be order- or repetition-sensitive.
|
|
self.assertEqual(result.count("-fno-exceptions"), 3)
|
|
self.assertIn("removed 8 repeated external build flags", output)
|
|
|
|
def test_two_token_options_remain_paired_and_quoted(self):
|
|
overlay = "-D FEATURE=1 -I 'path with spaces'"
|
|
flags = [overlay, "-DLOCAL=1", overlay]
|
|
|
|
environment, _output, namespace = apply_policy(flags, overlay)
|
|
logical = namespace["logical_flags"](
|
|
environment.values["BUILD_FLAGS"]
|
|
)
|
|
|
|
self.assertEqual(logical.count(("-D", "FEATURE=1")), 1)
|
|
self.assertEqual(logical.count(("-I", "path with spaces")), 2)
|
|
self.assertIn(("-DLOCAL=1",), logical)
|
|
|
|
def test_shell_tokens_round_trip_without_changing_quoting_semantics(self):
|
|
raw = (
|
|
"-D NAME='\"quoted value\"' -U OLD -I 'include path' "
|
|
"-L lib -l mesh -F frameworks -arch xtensa "
|
|
"-include 'config file.h' -imacros macros.h "
|
|
"-isysroot sdk -isystem system -iquote quoted "
|
|
"-idirafter after --param max-inline-insns-single=20 "
|
|
"-Wl,--wrap=target $PROJECT_DIR/source.cpp"
|
|
)
|
|
_environment, _output, namespace = apply_policy(raw, "")
|
|
logical = namespace["logical_flags"](raw)
|
|
serialized = namespace["serialize_flags"](logical)
|
|
|
|
self.assertEqual(flatten(serialized), shlex.split(raw))
|
|
|
|
def test_only_column_zero_bang_is_preserved_as_a_command(self):
|
|
command = "!python make_flags.py --profile full"
|
|
quoted_literal = "'!not-a-command'"
|
|
indented_literal = " !also-not-a-command"
|
|
flags = [command, quoted_literal, indented_literal, "-DOVERLAY=1"]
|
|
|
|
environment, _output, _namespace = apply_policy(
|
|
flags, "-DOVERLAY=1"
|
|
)
|
|
result = environment.values["BUILD_FLAGS"]
|
|
|
|
self.assertEqual(result[0], command)
|
|
self.assertFalse(result[1].startswith("!"))
|
|
self.assertFalse(result[2].startswith("!"))
|
|
self.assertEqual(shlex.split(result[1]), ["!not-a-command"])
|
|
self.assertEqual(shlex.split(result[2]), ["!also-not-a-command"])
|
|
|
|
def test_non_overlay_duplicates_and_non_full_build_are_untouched(self):
|
|
flags = ["-Wextra -DOVERLAY=1", "-Wextra -DOVERLAY=1"]
|
|
environment, _output, _namespace = apply_policy(
|
|
flags, "-DOVERLAY=1"
|
|
)
|
|
result = flatten(environment.values["BUILD_FLAGS"])
|
|
self.assertEqual(result.count("-Wextra"), 2)
|
|
self.assertEqual(result.count("-DOVERLAY=1"), 1)
|
|
|
|
environment, output, _namespace = apply_policy(
|
|
flags, "-DOVERLAY=1", full=False
|
|
)
|
|
self.assertEqual(environment.values["BUILD_FLAGS"], flags)
|
|
self.assertEqual(environment.replacements, [])
|
|
self.assertEqual(output, "")
|
|
|
|
def test_malformed_shell_quoting_fails_safe(self):
|
|
flags = ["-DBOARD=1", "-DNAME='unterminated"]
|
|
environment, output, _namespace = apply_policy(
|
|
flags, "-DOVERLAY=1"
|
|
)
|
|
|
|
self.assertEqual(environment.values["BUILD_FLAGS"], flags)
|
|
self.assertEqual(
|
|
[item for item in environment.replacements if "BUILD_FLAGS" in item],
|
|
[],
|
|
)
|
|
self.assertNotIn("removed", output)
|
|
|
|
def test_interposed_project_macros_keep_final_overlay_state(self):
|
|
overlay = "-DMODE=full -UOLD"
|
|
flags = [
|
|
overlay,
|
|
"-DMODE=project -DOLD=1",
|
|
overlay,
|
|
]
|
|
|
|
environment, _output, _namespace = apply_policy(flags, overlay)
|
|
result = flatten(environment.values["BUILD_FLAGS"])
|
|
|
|
self.assertEqual(
|
|
[token for token in result if token.startswith("-DMODE=")],
|
|
["-DMODE=project", "-DMODE=full"],
|
|
)
|
|
self.assertLess(result.index("-DOLD=1"), result.index("-UOLD"))
|
|
|
|
def test_commands_and_response_files_keep_duplicates(self):
|
|
command = "!python make_flags.py --profile full"
|
|
flags = [command, "@common.rsp -DOVERLAY=1"] * 2
|
|
|
|
environment, _output, _namespace = apply_policy(
|
|
flags, "@common.rsp -DOVERLAY=1"
|
|
)
|
|
result = environment.values["BUILD_FLAGS"]
|
|
|
|
self.assertEqual(result.count(command), 2)
|
|
self.assertEqual(flatten(result).count("@common.rsp"), 2)
|
|
self.assertEqual(flatten(result).count("-DOVERLAY=1"), 1)
|
|
|
|
def test_include_order_and_repeated_inputs_are_not_deduplicated(self):
|
|
overlay = "-I external -include forced.h -l mesh -DFEATURE=1"
|
|
flags = [
|
|
overlay,
|
|
"-I project -include forced.h -l mesh",
|
|
overlay,
|
|
]
|
|
|
|
environment, _output, _namespace = apply_policy(flags, overlay)
|
|
result = flatten(environment.values["BUILD_FLAGS"])
|
|
|
|
self.assertEqual(
|
|
[token for token in result if token in {"external", "project"}],
|
|
["external", "project", "external"],
|
|
)
|
|
self.assertEqual(result.count("forced.h"), 3)
|
|
self.assertEqual(result.count("mesh"), 3)
|
|
self.assertEqual(result.count("-DFEATURE=1"), 1)
|
|
|
|
def test_cpppath_shortening_preserves_order_and_missing_entries(self):
|
|
_environment, _output, namespace = apply_policy("", "")
|
|
project = str(ROOT)
|
|
framework = str(
|
|
ROOT / "very-long-framework-directory" / "include"
|
|
)
|
|
missing = str(ROOT / "does-not-exist" / "include")
|
|
aliases = {
|
|
os.path.abspath(framework): "C:/PIO/FRAMEW~1/include",
|
|
os.path.abspath(missing): None,
|
|
os.path.abspath(ROOT / "include"): "C:/MC/include",
|
|
}
|
|
|
|
shortened, saved = namespace["shorten_cpppaths"](
|
|
["include", framework, missing],
|
|
project,
|
|
aliases.get,
|
|
)
|
|
|
|
# The relative project include is already shorter than its alias.
|
|
self.assertEqual(shortened[0], "include")
|
|
self.assertEqual(shortened[1], "C:/PIO/FRAMEW~1/include")
|
|
self.assertEqual(shortened[2], missing)
|
|
self.assertEqual(
|
|
saved, len(framework) - len("C:/PIO/FRAMEW~1/include")
|
|
)
|
|
|
|
def test_full_build_driver_installs_the_pre_script(self):
|
|
build_script = BUILD_SCRIPT.read_text(encoding="utf-8")
|
|
full_block_start = build_script.index(
|
|
'if [ "$ESP32_FULL_BUILD" = "1" ] || '
|
|
'is_esp32_companion_radio_full_target "$env_name"; then'
|
|
)
|
|
full_block_end = build_script.index("\n else", full_block_start)
|
|
full_block = build_script[full_block_start:full_block_end]
|
|
self.assertIn(
|
|
'"pre:scripts/deduplicate_full_build_flags.py"', full_block
|
|
)
|
|
|
|
def test_windows_shortener_waits_for_companion_project_sources(self):
|
|
environment, _output, _namespace = apply_policy("", "")
|
|
if os.name == "nt":
|
|
self.assertEqual(len(environment.middlewares), 1)
|
|
self.assertEqual(
|
|
environment.middlewares[0][1],
|
|
"*examples*companion_radio*.cpp",
|
|
)
|
|
|
|
environment, _output, _namespace = apply_policy(
|
|
"", "", companion=False
|
|
)
|
|
self.assertEqual(environment.middlewares, [])
|
|
|
|
def test_middleware_pattern_excludes_dependencies_with_full_env_name(self):
|
|
pattern = "*examples*companion_radio*.cpp"
|
|
self.assertTrue(
|
|
fnmatch.fnmatch(
|
|
".pio/build/heltec_full/examples/companion_radio/MyMesh.cpp",
|
|
pattern,
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
fnmatch.fnmatch(
|
|
".pio/build/heltec_companion_radio_full/"
|
|
"lib123/ArduinoJson/src/Json.cpp",
|
|
pattern,
|
|
)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|