mirror of
https://github.com/esphome/esphome.git
synced 2026-09-12 07:47:33 +00:00
Merge branch 'host-pch' into esp32-pio-pch
This commit is contained in:
@@ -319,6 +319,154 @@ def lint_no_long_delays(fname, match):
|
||||
)
|
||||
|
||||
|
||||
# An if/else/for/while whose only body is an unbraced ESP_LOG*() call. When the build's compile-time
|
||||
# log level drops that macro, the body expands to nothing and the compiler warns (-Wempty-body).
|
||||
# clang-tidy's brace check does not catch these (ShortStatementLines allows short unbraced bodies), so
|
||||
# this fills that gap. Matched against comment/string-masked content, so commented-out or quoted code
|
||||
# is ignored. Both spellings are covered: core/log.h defines the uppercase ESP_LOG*() macros and
|
||||
# the lowercase esph_log_*() ones, and both expand to nothing below their log level.
|
||||
# 'for' allows ';' inside its parentheses (the classic C-style header); 'if'/'while' do not, so their
|
||||
# condition cannot run past the statement it guards. The 'for' header permits one level of nested
|
||||
# parens so it stays bounded to its own statement: without that, it can run past the loop body and
|
||||
# latch onto a later ')', mis-reporting the line and skipping the '#' preprocessor check below.
|
||||
ESP_LOG_NEEDS_BRACES_RE = re.compile(
|
||||
r"(?:\bif\s*\([^{};]*\)|\bwhile\s*\([^{};]*\)|\bfor\s*\((?:[^{}()]|\([^{}()]*\))*\)|\belse\b)"
|
||||
r"[ \t]*\n?[ \t]*(?:ESP_LOG[A-Z]*|esph_log_[a-z]+)\s*\(",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _mask_cpp_comments_strings(s):
|
||||
"""Return s with // and /* */ comments and string/char/raw-string literals blanked to spaces
|
||||
(length and newlines preserved) so a regex only matches real code. Parentheses in real code are
|
||||
kept, so callers can still balance them on the masked text."""
|
||||
out = list(s)
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
# Raw string literal: an optional encoding prefix, then R"delim( ... )delim". The body may
|
||||
# contain quotes, //, /* and unbalanced parens, so it must be consumed as one unit.
|
||||
if c == "R" and i + 1 < n and s[i + 1] == '"':
|
||||
j = i + 2
|
||||
delim = ""
|
||||
while j < n and s[j] not in "( \t\r\n\\" and len(delim) < 16:
|
||||
delim += s[j]
|
||||
j += 1
|
||||
if j < n and s[j] == "(":
|
||||
closing = ")" + delim + '"'
|
||||
end = s.find(closing, j + 1)
|
||||
end = n if end == -1 else end + len(closing)
|
||||
for k in range(i, end):
|
||||
if s[k] != "\n":
|
||||
out[k] = " "
|
||||
i = end
|
||||
continue
|
||||
i += 1
|
||||
elif c == "/" and i + 1 < n and s[i + 1] == "/":
|
||||
while i < n and s[i] != "\n":
|
||||
out[i] = " "
|
||||
i += 1
|
||||
elif c == "/" and i + 1 < n and s[i + 1] == "*":
|
||||
out[i] = out[i + 1] = " "
|
||||
i += 2
|
||||
while i < n and not (s[i] == "*" and i + 1 < n and s[i + 1] == "/"):
|
||||
if s[i] != "\n":
|
||||
out[i] = " "
|
||||
i += 1
|
||||
if i < n:
|
||||
out[i] = " "
|
||||
if i + 1 < n:
|
||||
out[i + 1] = " "
|
||||
i += 2
|
||||
# A "'" after an alphanumeric or '_' is a C++ digit separator (1'000), not a literal opener.
|
||||
elif c == '"' or (
|
||||
c == "'" and not (i and (s[i - 1].isalnum() or s[i - 1] == "_"))
|
||||
):
|
||||
quote = c
|
||||
out[i] = " "
|
||||
i += 1
|
||||
while i < n:
|
||||
if s[i] == "\\":
|
||||
out[i] = " "
|
||||
if i + 1 < n:
|
||||
out[i + 1] = " "
|
||||
i += 2
|
||||
continue
|
||||
if s[i] == quote:
|
||||
out[i] = " "
|
||||
i += 1
|
||||
break
|
||||
if s[i] != "\n":
|
||||
out[i] = " "
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _log_statement_end(masked, open_paren):
|
||||
"""Index of the ';' ending the ESP_LOG call whose '(' is at open_paren, or None. Balanced on the
|
||||
masked text so quotes/comments inside the arguments do not confuse the paren count."""
|
||||
depth = 0
|
||||
i = open_paren
|
||||
n = len(masked)
|
||||
while i < n:
|
||||
ch = masked[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
j = i + 1
|
||||
while j < n and masked[j] != ";":
|
||||
if not masked[j].isspace():
|
||||
return None
|
||||
j += 1
|
||||
return j if j < n else None
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
@lint_content_check(include=cpp_include)
|
||||
def lint_esp_log_needs_braces(fname, content):
|
||||
# Cheap bailout: no log call means nothing to flag, and skips masking the file entirely.
|
||||
if "ESP_LOG" not in content and "esph_log_" not in content:
|
||||
return []
|
||||
masked = _mask_cpp_comments_strings(content)
|
||||
errors = []
|
||||
for match in ESP_LOG_NEEDS_BRACES_RE.finditer(masked):
|
||||
pos = match.start()
|
||||
line_start = content.rfind("\n", 0, pos) + 1
|
||||
# Skip preprocessor conditionals (#if/#else/#elif): not C++ control statements.
|
||||
if content[line_start:pos].lstrip().startswith("#"):
|
||||
continue
|
||||
# A '// NOLINT' may sit at the end of the log line (where the message says to put it) or on the
|
||||
# control-statement line, so scan the whole statement rather than only up to the ESP_LOG token.
|
||||
stmt_end = _log_statement_end(masked, match.end() - 1)
|
||||
nolint_end = (
|
||||
content.find("\n", stmt_end) if stmt_end is not None else match.end()
|
||||
)
|
||||
if nolint_end == -1:
|
||||
nolint_end = len(content)
|
||||
if "NOLINT" in content[pos:nolint_end]:
|
||||
continue
|
||||
snippet = content[pos : match.end()].replace("\n", " ").strip()
|
||||
errors.append(
|
||||
(
|
||||
content.count("\n", 0, pos) + 1,
|
||||
pos - line_start + 1,
|
||||
(
|
||||
f"{highlight(snippet)} - an if/else/for/while body that is a single log "
|
||||
"call must be wrapped in braces. When the log level compiles the macro out, the "
|
||||
"body becomes empty and the compiler warns (-Wempty-body). Add { } around the "
|
||||
"log call (or a '// NOLINT' comment if this is genuinely intended)."
|
||||
),
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
@lint_content_check(
|
||||
include=[
|
||||
"esphome/const.py",
|
||||
|
||||
+53
-29
@@ -54,8 +54,10 @@ from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -68,7 +70,9 @@ from clang_tidy_hash import (
|
||||
from helpers import (
|
||||
CPP_FILE_EXTENSIONS,
|
||||
ESPHOME_TESTS_COMPONENTS_PATH,
|
||||
INTEGRATION_TESTS_PATH,
|
||||
PYTHON_FILE_EXTENSIONS,
|
||||
all_integration_test_files,
|
||||
base_python_changed,
|
||||
changed_files,
|
||||
core_changed,
|
||||
@@ -84,6 +88,8 @@ from helpers import (
|
||||
get_target_branch,
|
||||
git_ls_files,
|
||||
is_validate_only_file,
|
||||
load_integration_durations,
|
||||
lpt_partition,
|
||||
root_path,
|
||||
)
|
||||
from split_components_for_ci import create_intelligent_batches
|
||||
@@ -97,24 +103,24 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65
|
||||
# Isolated components count as 10x, groupable components count as 1x
|
||||
COMPONENT_TEST_BATCH_SIZE = 40
|
||||
|
||||
# Integration test bucketing: when more than the threshold tests are scheduled,
|
||||
# fan out across this many parallel jobs. Below the threshold, a single job runs.
|
||||
# Above the threshold, fan out across up to this many jobs, balanced by the
|
||||
# recorded per-file durations. The target is serial junit-time weight per
|
||||
# bucket, not wall time (calibrated with the conftest compile cap); it
|
||||
# sizes the bucket count for small subsets.
|
||||
INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
|
||||
INTEGRATION_TESTS_SPLIT_BUCKETS = 3
|
||||
INTEGRATION_TESTS_SPLIT_BUCKETS = 5
|
||||
INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0
|
||||
|
||||
|
||||
def _split_list(items: list[str], n: int) -> list[list[str]]:
|
||||
"""Split a list into n roughly-equal contiguous parts (matches script/clang-tidy)."""
|
||||
k, m = divmod(len(items), n)
|
||||
return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)]
|
||||
|
||||
|
||||
def _all_integration_test_files() -> list[str]:
|
||||
"""Return all integration test file paths, sorted, relative to repo root."""
|
||||
return sorted(
|
||||
str(p.relative_to(root_path))
|
||||
for p in (Path(root_path) / "tests" / "integration").glob("test_*.py")
|
||||
)
|
||||
# platformio and aioesphomeapi (requirements.txt), the pytest stack
|
||||
# (requirements_test.txt) and the fixture every session compiles; a change
|
||||
# to any runs the full matrix
|
||||
INTEGRATION_TESTS_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"requirements.txt",
|
||||
"requirements_test.txt",
|
||||
"tests/integration/fixtures/cache_init.yaml",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _compute_integration_test_buckets(
|
||||
@@ -123,7 +129,7 @@ def _compute_integration_test_buckets(
|
||||
) -> tuple[bool, list[dict[str, Any]]]:
|
||||
"""Compute (run_integration, buckets) from the determine_integration_tests result.
|
||||
|
||||
Pure function for unit testing — no I/O beyond `_all_integration_test_files`
|
||||
Pure function for unit testing — no I/O beyond `all_integration_test_files`
|
||||
when `integration_run_all` is set.
|
||||
|
||||
`buckets` is a list of `{name, tests}` dicts where `tests` is a JSON-friendly
|
||||
@@ -131,7 +137,7 @@ def _compute_integration_test_buckets(
|
||||
shell word-splitting / glob hazards.
|
||||
"""
|
||||
if integration_run_all:
|
||||
files = _all_integration_test_files()
|
||||
files = all_integration_test_files()
|
||||
else:
|
||||
files = sorted(integration_test_files)
|
||||
|
||||
@@ -142,12 +148,23 @@ def _compute_integration_test_buckets(
|
||||
return False, []
|
||||
|
||||
if len(files) > INTEGRATION_TESTS_SPLIT_THRESHOLD:
|
||||
parts = [
|
||||
part for part in _split_list(files, INTEGRATION_TESTS_SPLIT_BUCKETS) if part
|
||||
]
|
||||
durations = load_integration_durations()
|
||||
# Unrecorded files weigh the recording's median; with no recording a
|
||||
# file weighs a whole bucket, which keeps the full fan-out
|
||||
default = (
|
||||
statistics.median(durations.values())
|
||||
if durations
|
||||
else INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT
|
||||
)
|
||||
weights = {f: durations.get(f, default) for f in files}
|
||||
count = min(
|
||||
INTEGRATION_TESTS_SPLIT_BUCKETS,
|
||||
math.ceil(sum(weights.values()) / INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT),
|
||||
)
|
||||
# count <= SPLIT_BUCKETS < threshold < len(files): no group is empty
|
||||
parts = [sorted(part) for part in lpt_partition(files, weights, count)]
|
||||
buckets = [
|
||||
{"name": f"{i + 1}/{len(parts)}", "tests": part}
|
||||
for i, part in enumerate(parts)
|
||||
{"name": f"{i + 1}/{count}", "tests": part} for i, part in enumerate(parts)
|
||||
]
|
||||
else:
|
||||
buckets = [{"name": "1/1", "tests": files}]
|
||||
@@ -222,12 +239,15 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
|
||||
3. Integration test infrastructure files changed
|
||||
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
|
||||
|
||||
4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed
|
||||
- The dependency pins and the session init fixture affect every test
|
||||
|
||||
Returns (run_all=False, [test_files...]) when:
|
||||
|
||||
4. Specific integration test files changed
|
||||
5. Specific integration test files changed
|
||||
- Only those specific test files are returned
|
||||
|
||||
5. Components used by integration tests (or their dependencies) changed
|
||||
6. Components used by integration tests (or their dependencies) changed
|
||||
- Only test files whose fixtures use the changed components are returned
|
||||
|
||||
Args:
|
||||
@@ -245,12 +265,15 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
|
||||
# If any core files changed, run all integration tests
|
||||
return (True, [])
|
||||
|
||||
if any(f in INTEGRATION_TESTS_TRIGGER_FILES for f in files):
|
||||
return (True, [])
|
||||
|
||||
# If infrastructure Python files changed (conftest, utils, etc.), run all tests
|
||||
# Excludes test files (test_*.py), fixtures, and non-Python files (README.md)
|
||||
if any(
|
||||
f.startswith("tests/integration/")
|
||||
f.startswith(INTEGRATION_TESTS_PATH)
|
||||
and f.endswith(".py")
|
||||
and not f.startswith("tests/integration/test_")
|
||||
and not f.startswith(f"{INTEGRATION_TESTS_PATH}test_")
|
||||
and "/fixtures/" not in f
|
||||
for f in files
|
||||
):
|
||||
@@ -261,9 +284,9 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
|
||||
fixture_to_test_files = get_fixture_to_test_files()
|
||||
|
||||
for f in files:
|
||||
if f.startswith("tests/integration/test_") and f.endswith(".py"):
|
||||
if f.startswith(f"{INTEGRATION_TESTS_PATH}test_") and f.endswith(".py"):
|
||||
test_files.add(f)
|
||||
elif f.startswith("tests/integration/fixtures/"):
|
||||
elif f.startswith(f"{INTEGRATION_TESTS_PATH}fixtures/"):
|
||||
if f.endswith(".yaml"):
|
||||
# Fixture YAML changed - add corresponding test file(s)
|
||||
test_files.update(fixture_to_test_files.get(Path(f).stem, ()))
|
||||
@@ -1509,6 +1532,7 @@ def main() -> None:
|
||||
output: dict[str, Any] = {
|
||||
"core_ci": run_core_ci,
|
||||
"integration_tests": run_integration,
|
||||
"integration_run_all": integration_run_all,
|
||||
"integration_test_buckets": integration_test_buckets,
|
||||
"clang_tidy": run_clang_tidy,
|
||||
"clang_tidy_mode": clang_tidy_mode,
|
||||
|
||||
@@ -43,6 +43,53 @@ ESPHOME_TESTS_COMPONENTS_PATH = "tests/components/"
|
||||
# Tuple of component and test paths for efficient startswith checks
|
||||
COMPONENT_AND_TESTS_PATHS = (ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH)
|
||||
|
||||
# Integration tests path prefix
|
||||
INTEGRATION_TESTS_PATH = "tests/integration/"
|
||||
|
||||
# Per-file integration test durations from CI junit output; shared by the
|
||||
# reader (determine-jobs) and writer (update_integration_test_durations)
|
||||
INTEGRATION_TEST_DURATIONS_FILE = "tests/integration/integration_test_durations.json"
|
||||
|
||||
|
||||
def all_integration_test_files() -> list[str]:
|
||||
"""Return all integration test file paths, sorted, relative to repo root."""
|
||||
return sorted(
|
||||
p.relative_to(root_path).as_posix()
|
||||
for p in (Path(root_path) / "tests" / "integration").glob("test_*.py")
|
||||
)
|
||||
|
||||
|
||||
def load_integration_durations() -> dict[str, float]:
|
||||
"""Return recorded per-file pytest durations in seconds; empty when unavailable."""
|
||||
try:
|
||||
raw = json.loads(
|
||||
(Path(root_path) / INTEGRATION_TEST_DURATIONS_FILE).read_text()
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
print(
|
||||
f"integration durations unavailable: expected an object, "
|
||||
f"got {type(raw).__name__}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return {}
|
||||
except (OSError, ValueError) as err:
|
||||
# The file ships in the repo; degrade to unweighted bucketing, loudly
|
||||
print(f"integration durations unavailable: {err}", file=sys.stderr)
|
||||
return {}
|
||||
durations = {
|
||||
key: seconds
|
||||
for key, value in raw.items()
|
||||
if isinstance(value, (int, float)) and (seconds := float(value)) > 0
|
||||
}
|
||||
if len(durations) != len(raw):
|
||||
# One bad entry must not discard the whole recording
|
||||
print(
|
||||
f"dropped {len(raw) - len(durations)} invalid duration entries",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return durations
|
||||
|
||||
|
||||
# Base bus components - these ARE the bus implementations and should not
|
||||
# be flagged as needing migration since they are the platform/base components
|
||||
BASE_BUS_COMPONENTS = {
|
||||
@@ -1545,3 +1592,21 @@ def get_cpp_changed_components(files: list[str]) -> list[str]:
|
||||
if file.startswith(ESPHOME_COMPONENTS_PATH):
|
||||
affected.update(find_children_of_component(components_graph, component))
|
||||
return sorted(c for c in affected if has_cpp_unit_tests(c, tests_dir))
|
||||
|
||||
|
||||
def lpt_partition(
|
||||
items: list[str], weights: dict[str, float], count: int
|
||||
) -> list[list[str]]:
|
||||
"""Partition items into `count` weight-balanced groups (LPT greedy).
|
||||
|
||||
Heaviest item first into the lightest group. Ties keep input order, so
|
||||
pass pre-sorted items for deterministic output. script/clang-tidy's
|
||||
split_list is the unweighted contiguous sibling.
|
||||
"""
|
||||
groups: list[list[str]] = [[] for _ in range(count)]
|
||||
group_weights = [0.0] * count
|
||||
for item in sorted(items, key=lambda i: -weights[i]):
|
||||
lightest = min(range(count), key=group_weights.__getitem__)
|
||||
groups[lightest].append(item)
|
||||
group_weights[lightest] += weights[item]
|
||||
return groups
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge CI junit output into tests/integration/integration_test_durations.json.
|
||||
|
||||
The integration-tests CI job uploads one junit XML artifact per bucket on
|
||||
full matrix dev runs. Download a run's artifacts and merge the per file
|
||||
durations into the recording used by script/determine-jobs.py:
|
||||
|
||||
gh run download <run-id> --repo esphome/esphome -p "junit-integration-*" -D /tmp/junit
|
||||
script/update_integration_test_durations.py /tmp/junit
|
||||
|
||||
Missing files keep their previous recording and deleted files drop out; a
|
||||
run covering under 90% of the test files aborts unless --allow-partial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from helpers import (
|
||||
INTEGRATION_TEST_DURATIONS_FILE,
|
||||
INTEGRATION_TESTS_PATH,
|
||||
all_integration_test_files,
|
||||
load_integration_durations,
|
||||
root_path,
|
||||
)
|
||||
|
||||
DURATIONS_FILE = Path(root_path) / INTEGRATION_TEST_DURATIONS_FILE
|
||||
MIN_COVERAGE = 0.9
|
||||
# Exit code for the expected "run covers too few files" refusal, so the
|
||||
# refresh workflow can move on to the next candidate run
|
||||
EXIT_LOW_COVERAGE = 3
|
||||
|
||||
|
||||
def collect_durations(junit_dir: Path, known_files: set[str]) -> dict[str, float]:
|
||||
"""Sum junit testcase times per integration test file, in seconds."""
|
||||
durations: defaultdict[str, float] = defaultdict(float)
|
||||
unmatched = 0
|
||||
xml_files = sorted(junit_dir.rglob("*.xml"))
|
||||
if not xml_files:
|
||||
raise SystemExit(f"no junit XML files found under {junit_dir}")
|
||||
for xml_file in xml_files:
|
||||
for testcase in ET.parse(xml_file).getroot().iter("testcase"):
|
||||
# Skipped/errored testcases carry time="0"; recording them would
|
||||
# overwrite a good previous duration
|
||||
if any(
|
||||
testcase.find(tag) is not None
|
||||
for tag in ("skipped", "error", "failure")
|
||||
):
|
||||
continue
|
||||
# classname is the dotted module plus any test class, e.g.
|
||||
# tests.integration.test_x or tests.integration.test_x.TestFoo
|
||||
parts = testcase.get("classname", "").split(".")
|
||||
if parts[:2] != ["tests", "integration"] or len(parts) < 3:
|
||||
unmatched += 1
|
||||
continue
|
||||
path = f"{INTEGRATION_TESTS_PATH}{parts[2]}.py"
|
||||
if path not in known_files:
|
||||
print(f"skipping unknown test module {path}", file=sys.stderr)
|
||||
continue
|
||||
durations[path] += float(testcase.get("time", "0"))
|
||||
if unmatched:
|
||||
# A junit naming change would otherwise shrink the recording silently
|
||||
raise SystemExit(
|
||||
f"{unmatched} testcases with unexpected classnames; the junit layout changed"
|
||||
)
|
||||
# An all-skipped file totals 0.0; let the merge keep its previous entry
|
||||
return {k: v for k, v in durations.items() if v > 0}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"junit_dir", type=Path, help="directory containing downloaded junit XML files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-partial",
|
||||
action="store_true",
|
||||
help="merge a run covering under 90%% of the test files",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
on_disk = set(all_integration_test_files())
|
||||
if not on_disk:
|
||||
raise SystemExit("no integration test files found; wrong checkout root?")
|
||||
collected = collect_durations(args.junit_dir, on_disk)
|
||||
coverage = len(collected.keys() & on_disk) / len(on_disk)
|
||||
if coverage < MIN_COVERAGE and not args.allow_partial:
|
||||
print(
|
||||
f"artifacts cover only {coverage:.0%} of {len(on_disk)} test files; "
|
||||
"use a full matrix run or pass --allow-partial to merge anyway",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_LOW_COVERAGE
|
||||
|
||||
# Validated load: a bad previous entry cannot survive the round trip, and
|
||||
# an unreadable file aborts rather than being overwritten
|
||||
previous = load_integration_durations()
|
||||
if DURATIONS_FILE.is_file() and not previous:
|
||||
raise SystemExit(f"{DURATIONS_FILE} is unreadable; refusing to overwrite it")
|
||||
# New recordings win, absent files keep theirs, deleted files drop out
|
||||
merged = {
|
||||
path: collected.get(path, previous.get(path))
|
||||
for path in sorted(on_disk)
|
||||
if path in collected or path in previous
|
||||
}
|
||||
DURATIONS_FILE.write_text(
|
||||
json.dumps({k: round(v, 2) for k, v in merged.items()}, indent=2) + "\n"
|
||||
)
|
||||
print(f"wrote {len(merged)} entries to {DURATIONS_FILE} ({coverage:.0%} fresh)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user