Skip to content

Commit 52f1ed7

Browse files
committed
penguin: fixup patch conflict warnings
1 parent 32de928 commit 52f1ed7

File tree

4 files changed

+89
-30
lines changed

4 files changed

+89
-30
lines changed

src/penguin/common.py

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44
import coloredlogs
55
import yaml
6-
from os.path import join, isfile
6+
from os.path import join, isfile, basename
77
from yamlcore import CoreDumper, CoreLoader
88

99

@@ -54,21 +54,44 @@ def hash_yaml(section_to_hash):
5454
return hash_digest
5555

5656

57-
def patch_config(logger, base_config, patch):
57+
def patch_config(logger, base_config, patch, patch_name="patch", origin_map=None, verbose=False):
58+
# Initialize origin map if it wasn't passed in
59+
if origin_map is None:
60+
origin_map = {}
61+
62+
# Helper to recursively claim ownership of keys in the origin map
63+
def _record_origins(obj, path_prefix, source_name):
64+
if hasattr(obj, "model_fields_set"):
65+
for k in obj.model_fields_set:
66+
_record_origins(getattr(obj, k), f"{path_prefix}.{k}" if path_prefix else k, source_name)
67+
if obj.model_extra is not None:
68+
for k, val in obj.model_extra.items():
69+
_record_origins(val, f"{path_prefix}.{k}" if path_prefix else k, source_name)
70+
elif isinstance(obj, dict):
71+
for k, val in obj.items():
72+
_record_origins(val, f"{path_prefix}.{k}" if path_prefix else k, source_name)
73+
else:
74+
# Leaves and lists get recorded directly
75+
origin_map[path_prefix] = source_name
76+
5877
if not patch:
5978
# Empty patch, possibly an empty file or one with all comments
6079
return base_config
6180

81+
# If this is the very first run, populate the origin map with the base config
82+
if not origin_map:
83+
_record_origins(base_config, "", "base_config")
84+
6285
# Merge configs.
6386
def _recursive_update(base, new, config_option):
6487
if base is None:
88+
_record_origins(new, config_option, patch_name)
6589
return new
6690
if new is None:
6791
return base
6892

69-
# assert type(base) is type(new)
70-
7193
if hasattr(base, "merge"):
94+
origin_map[config_option] = patch_name
7295
return base.merge(new)
7396

7497
if hasattr(base, "model_fields_set"):
@@ -80,61 +103,87 @@ def _recursive_update(base, new, config_option):
80103
result[base_key] = base_value
81104
for new_key in new.model_fields_set:
82105
new_value = getattr(new, new_key)
106+
full_path = f"{config_option}.{new_key}" if config_option else new_key
83107
if new_key in result:
84108
result[new_key] = _recursive_update(
85109
result[new_key],
86110
new_value,
87-
f"{config_option}.{new_key}" if config_option else new_key,
111+
full_path,
88112
)
89113
else:
90114
result[new_key] = new_value
115+
_record_origins(new_value, full_path, patch_name)
116+
91117
if new.model_extra is not None:
92118
for new_key, new_value in new.model_extra.items():
119+
full_path = f"{config_option}.{new_key}" if config_option else new_key
93120
if new_key in result:
94121
result[new_key] = _recursive_update(
95122
result[new_key],
96123
new_value,
97-
f"{config_option}.{new_key}" if config_option else new_key,
124+
full_path,
98125
)
99126
else:
100127
result[new_key] = new_value
128+
_record_origins(new_value, full_path, patch_name)
101129
return type(base)(**result)
102130

103131
if isinstance(base, list):
132+
# We treat list appends differently, no "conflict" per se, just an addition
104133
return base + new
105134

106135
if isinstance(base, dict):
107136
result = dict()
108137
for key, base_value in base.items():
138+
full_path = f"{config_option}.{key}" if config_option else key
109139
if key in new:
110140
new_value = new[key]
111141
result[key] = _recursive_update(
112142
base_value,
113143
new_value,
114-
f"{config_option}.{key}" if config_option else key,
144+
full_path,
115145
)
116146
else:
117147
result[key] = base_value
118148
for new_key, new_value in new.items():
119149
if new_key not in base:
150+
full_path = f"{config_option}.{new_key}" if config_option else new_key
120151
result[new_key] = new_value
152+
_record_origins(new_value, full_path, patch_name)
121153
return result
122154

123155
if base == new:
124156
return base
125157

126-
base_str = yaml.dump(base).strip().removesuffix("...").strip()
127-
new_str = yaml.dump(new).strip().removesuffix("...").strip()
128-
change_str = (
129-
f"\n```\n{base_str}\n```↓\n```\n{new_str}\n```"
130-
if "\n" in base_str + new_str
131-
else f"`{base_str}` → `{new_str}`"
132-
)
133-
logger.warning(f"patch conflict: {config_option}: {change_str}")
158+
# --> WE HAVE A CONFLICT <--
159+
previous_source = origin_map.get(config_option, "base_config")
160+
161+
# Clean up long paths to just the filenames
162+
prev_file = basename(previous_source)
163+
new_file = basename(patch_name)
164+
165+
# Strip out Pydantic '.root' noise from the config key
166+
clean_option = config_option.replace(".root", "")
167+
168+
if verbose:
169+
base_str = yaml.dump(base).strip().removesuffix("...").strip()
170+
new_str = yaml.dump(new).strip().removesuffix("...").strip()
171+
change_str = (
172+
f"\n```\n{base_str}\n```↓\n```\n{new_str}\n```"
173+
if "\n" in base_str + new_str
174+
else f"`{base_str}` → `{new_str}`"
175+
)
176+
177+
# Use a much tighter logging format
178+
logger.info(
179+
f"conflict: {clean_option}: {change_str} ({prev_file} -> {new_file})"
180+
)
134181

182+
# Claim ownership of the newly overwritten key
183+
origin_map[config_option] = patch_name
135184
return new
136185

137-
return _recursive_update(base_config, patch, None)
186+
return _recursive_update(base_config, patch, "")
138187

139188

140189
class PathHighlightingFormatter(coloredlogs.ColoredFormatter):

src/penguin/manager.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import signal
2121
import subprocess
2222
import time
23-
import logging
2423
from penguin import getColoredLogger
2524
from .common import yaml
2625

@@ -74,7 +73,7 @@ def calculate_score(result_dir: str, have_console: bool = True) -> dict[str, int
7473
# --- Load core_config.yaml ---
7574
try:
7675
with open(os.path.join(result_dir, "core_config.yaml")) as f:
77-
config = yaml.safe_load(f) or {} # Ensure config is a dict even if file is empty
76+
config = yaml.safe_load(f) or {} # Ensure config is a dict even if file is empty
7877
except FileNotFoundError:
7978
logger.warning(f"Config file not found in {result_dir}. Cannot determine blocked signals.")
8079
except yaml.YAMLError as e:
@@ -83,14 +82,13 @@ def calculate_score(result_dir: str, have_console: bool = True) -> dict[str, int
8382
# --- System Health: execs, sockets, devices ---
8483
try:
8584
with open(os.path.join(result_dir, "health_final.yaml")) as f:
86-
health_data = yaml.safe_load(f) or {} # Ensure health_data is a dict
85+
health_data = yaml.safe_load(f) or {} # Ensure health_data is a dict
8786
except FileNotFoundError:
8887
# Instead of returning {}, just log a warning and continue. Scores will default to 0.
8988
logger.warning(f"{result_dir}/health_final.yaml not found - health scores will be 0.")
9089
except yaml.YAMLError as e:
9190
logger.error(f"Error parsing health_final.yaml in {result_dir}: {e}")
9291

93-
9492
# --- Panic or not (nopanic) ---
9593
console_log_path = os.path.join(result_dir, "console.log")
9694
if have_console and os.path.isfile(console_log_path):
@@ -134,7 +132,6 @@ def calculate_score(result_dir: str, have_console: bool = True) -> dict[str, int
134132
except (IOError, csv.Error) as e:
135133
logger.error(f"Could not read or parse {coverage_csv_path}: {e}")
136134

137-
138135
if config:
139136
blocked_signals = -len(config.get("blocked_signals", []))
140137

src/penguin/penguin_config/__init__.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,15 @@ def load_unpatched_config(path):
191191
return config
192192

193193

194-
def load_config(proj_dir, path, validate=True, resolved_kernel=None):
194+
def load_config(proj_dir, path, validate=True, resolved_kernel=None, verbose=False):
195195
"""Load penguin config from path"""
196196
with open(path, "r") as f:
197197
config = yaml.load(f, Loader=CoreLoader)
198198
config = structure.Patch(**config)
199+
200+
# 1. Initialize the empty map to track our provenance
201+
origin_map = {}
202+
199203
# look for files called patch_*.yaml in the same directory as the config file
200204
if config.core.auto_patching:
201205
patch_files = list(Path(proj_dir).glob("patch_*.yaml"))
@@ -213,12 +217,20 @@ def load_config(proj_dir, path, validate=True, resolved_kernel=None):
213217
# patches are loaded relative to the main config file
214218
patch_relocated = Path(proj_dir, patch)
215219
if patch_relocated.exists():
216-
# TODO: If we're missing a patch we should warn, but this happens 3-4x
217-
# and that's too verbose.
218220
with open(patch_relocated, "r") as f:
219-
patch = yaml.load(f, Loader=CoreLoader)
220-
patch = structure.Patch(**patch)
221-
config = patch_config(logger, config, patch)
221+
patch_data = yaml.load(f, Loader=CoreLoader)
222+
patch_data = structure.Patch(**patch_data)
223+
224+
# 2. Pass the origin map and the patch name down into the merger
225+
config = patch_config(
226+
logger=logger,
227+
base_config=config,
228+
patch=patch_data,
229+
patch_name=str(patch_relocated), # Give it a name to log
230+
origin_map=origin_map, # Pass the state map
231+
verbose=verbose
232+
)
233+
222234
config = config.model_dump()
223235
if config["core"].get("guest_cmd", False) is True:
224236
config["static_files"]["/igloo/utils/guesthopper"] = dict(
@@ -249,7 +261,8 @@ def load_config(proj_dir, path, validate=True, resolved_kernel=None):
249261
if Path(proj_dir, "base/fs.tar.gz").exists():
250262
config["core"]["fs"] = "./base/fs.tar.gz"
251263
else:
252-
logger.info("No core.fs specified in config - using empty fs - most likely a test")
264+
if verbose:
265+
logger.info("No core.fs specified in config - using empty fs - most likely a test")
253266
config["core"]["fs"] = "./base/empty_fs.tar.gz"
254267
empty_fs_path = os.path.join(proj_dir, "./base/empty_fs.tar.gz")
255268
if not os.path.exists(empty_fs_path):

src/penguin/penguin_run.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ def run_config(
110110
# Read input config and validate
111111
if resolved_kernel:
112112
logger.info(f"Using pre-resolved kernel: {resolved_kernel}")
113-
conf = load_config(proj_dir, conf_yaml, resolved_kernel=resolved_kernel)
113+
conf = load_config(proj_dir, conf_yaml, resolved_kernel=resolved_kernel, verbose=True)
114114
else:
115-
conf = load_config(proj_dir, conf_yaml)
115+
conf = load_config(proj_dir, conf_yaml, verbose=True)
116116

117117
pkversion = get_penguin_kernel_version(conf)
118118

0 commit comments

Comments
 (0)