LiteBuild 0.5__tar.gz → 0.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {litebuild-0.5 → litebuild-0.7}/LiteBuild/build_engine.py +132 -44
  2. {litebuild-0.5 → litebuild-0.7}/LiteBuild/build_logger.py +0 -1
  3. litebuild-0.7/LiteBuild/build_util.py +43 -0
  4. litebuild-0.7/LiteBuild/build_workers.py +299 -0
  5. {litebuild-0.5 → litebuild-0.7}/LiteBuild/command_generator.py +31 -21
  6. {litebuild-0.5 → litebuild-0.7}/LiteBuild/lite_build_controller.py +6 -9
  7. {litebuild-0.5 → litebuild-0.7}/LiteBuild/lite_build_runner.py +47 -40
  8. litebuild-0.7/LiteBuild/litebuild.py +274 -0
  9. {litebuild-0.5 → litebuild-0.7}/LiteBuild/schema.py +29 -11
  10. litebuild-0.7/LiteBuild.egg-info/PKG-INFO +273 -0
  11. {litebuild-0.5 → litebuild-0.7}/LiteBuild.egg-info/SOURCES.txt +1 -0
  12. {litebuild-0.5 → litebuild-0.7}/LiteBuild.egg-info/entry_points.txt +1 -0
  13. litebuild-0.7/PKG-INFO +273 -0
  14. litebuild-0.7/docs/readme.md +246 -0
  15. {litebuild-0.5 → litebuild-0.7}/pyproject.toml +2 -1
  16. litebuild-0.5/LiteBuild/build_workers.py +0 -244
  17. litebuild-0.5/LiteBuild/litebuild.py +0 -129
  18. litebuild-0.5/LiteBuild.egg-info/PKG-INFO +0 -139
  19. litebuild-0.5/PKG-INFO +0 -139
  20. litebuild-0.5/docs/readme.md +0 -112
  21. {litebuild-0.5 → litebuild-0.7}/LICENSE +0 -0
  22. {litebuild-0.5 → litebuild-0.7}/LiteBuild/config_loader.py +0 -0
  23. {litebuild-0.5 → litebuild-0.7}/LiteBuild/dependency_graph.py +0 -0
  24. {litebuild-0.5 → litebuild-0.7}/LiteBuild/sleep_inhibitor.py +0 -0
  25. {litebuild-0.5 → litebuild-0.7}/LiteBuild.egg-info/dependency_links.txt +0 -0
  26. {litebuild-0.5 → litebuild-0.7}/LiteBuild.egg-info/requires.txt +0 -0
  27. {litebuild-0.5 → litebuild-0.7}/LiteBuild.egg-info/top_level.txt +0 -0
  28. {litebuild-0.5 → litebuild-0.7}/setup.cfg +0 -0
  29. {litebuild-0.5 → litebuild-0.7}/tests/test_build_engine.py +0 -0
@@ -1,7 +1,7 @@
1
1
  from concurrent.futures import ProcessPoolExecutor
2
2
  import datetime
3
3
  import difflib
4
- from enum import IntEnum
4
+ from enum import IntEnum, StrEnum
5
5
  import json
6
6
  import os
7
7
  from pathlib import Path
@@ -13,10 +13,13 @@ from typing import List, Dict, Tuple, NamedTuple, Optional
13
13
  import networkx as nx
14
14
  from YMLEditor.yaml_reader import ConfigLoader
15
15
 
16
+
17
+ # build_engine.py
18
+
16
19
  from LiteBuild.build_logger import BuildLogger, setup_logger, get_logger
17
20
  from LiteBuild.command_generator import CommandGenerator
18
21
  from LiteBuild.dependency_graph import DependencyGraph
19
- from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator
22
+ from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator, YAMLSection
20
23
 
21
24
 
22
25
  class UpdateCode(IntEnum):
@@ -58,14 +61,15 @@ class BuildEngine:
58
61
  ):
59
62
  """Initializes the BuildEngine, merging command-line variables into the config."""
60
63
  if cli_vars:
61
- if "GENERAL" not in config_data:
62
- config_data["GENERAL"] = {}
63
- config_data["GENERAL"].update(cli_vars)
64
+ if YAMLSection.GENERAL not in config_data:
65
+ config_data[YAMLSection.GENERAL] = {}
66
+ config_data[YAMLSection.GENERAL].update(cli_vars)
64
67
 
65
68
  self.config = config_data
66
69
  self.state_file = state_file
70
+
67
71
  # Validate Input Directory
68
- input_dir = config_data.get("GENERAL", {}).get("INPUT_DIRECTORY")
72
+ input_dir = config_data.get(YAMLSection.GENERAL, {}).get("INPUT_DIRECTORY")
69
73
  if input_dir:
70
74
  path = Path(input_dir)
71
75
  if not path.exists():
@@ -93,7 +97,7 @@ class BuildEngine:
93
97
  return cls(config_data, cli_vars=cli_vars, state_file=state_file)
94
98
  except (FileNotFoundError, ValueError) as e:
95
99
  # Re-raise to be handled by the calling script (CLI or GUI)
96
- raise e
100
+ raise
97
101
 
98
102
  def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None, status_callback=None, force_rebuild=False):
99
103
  """
@@ -103,59 +107,106 @@ class BuildEngine:
103
107
  logger = get_logger()
104
108
  setup_logger(logger)
105
109
 
106
- # --- Extract Context info ---
107
- general_cfg = self.config.get("GENERAL", {})
108
- profile_cfg = self.config.get("PROFILES", {}).get(profile_name, {})
109
-
110
- # Get (optional) segment/category from General or Profile
111
- segment = general_cfg.get("SEGMENT") or profile_cfg.get("SEGMENT") or ""
112
- category = general_cfg.get("CATEGORY") or profile_cfg.get("CATEGORY") or ""
113
-
114
- context_str = f"Segment: {segment} Category: {category} "
110
+ context_str = ""
115
111
  if profile_name:
116
112
  context_str += f" Profile: {profile_name}"
117
113
 
118
- # Get the current date and time
119
- #now = datetime.now()
120
-
121
114
  # Format the time as a string (HH:MM:SS) using strftime()
122
115
  current_time = "" #now.strftime("%H:%M:%S")
123
116
 
124
- logger.log(f"🔵 Executing build for step {final_step_name} - {current_time}")
125
- logger.log(f"ℹ️ {context_str}\n")
117
+ logger.log(f"\n🔵 Executing build for target step: {final_step_name} - {current_time}")
118
+ profile_display = profile_name or "none"
119
+ logger.log(f"ℹ️ Profile: {profile_display}\n")
126
120
 
127
121
  try:
128
122
  state_manager = BuildStateManager(self.state_file)
129
123
  planner = BuildPlanner(self.config, state_manager.load_state())
130
124
  plan = planner.plan_build(profile_name, final_step_name, force_rebuild=force_rebuild)
131
-
132
125
  executor = BuildExecutor(state_manager, self.config)
133
126
  success = executor.execute_plan(plan, logger, status_callback=status_callback)
134
127
  except Exception as e:
135
128
  success = False
136
129
  # --- ERROR REPORTING ---
137
- #logger.log(f"\n❌ ERROR")
138
- logger.log(f"{e}")
139
- #logger.log("\n--- Traceback ---")
140
- #logger.log(traceback.format_exc())
130
+ logger.log(f"🔴{e}")
141
131
 
142
132
  # Update status callback if present
143
133
  if status_callback:
144
134
  status_callback("step", 0, 0, "error")
145
- # Get the current date and time
146
- #now = datetime.now()
147
135
 
148
136
  # Format the time as a string (HH:MM:SS) using strftime()
149
137
  current_time = "" #now.strftime("%H:%M:%S")
150
138
 
151
139
  if success:
152
140
  logger.log(f"\n✅ Build finished successfully. {current_time}")
141
+ return True
153
142
  else:
154
143
  logger.log(f"🔴Build failed for {final_step_name}. {current_time}")
144
+ return False
145
+
146
+ def get_group_list(self) -> dict:
147
+ """Returns the PROFILE_GROUPS in the YAML config."""
148
+ return self.config.get(YAMLSection.PROFILE_GROUPS, {})
149
+
150
+ def get_profile_list(self) -> dict:
151
+ """Returns the PROFILES in the YAML config."""
152
+ return self.config.get(YAMLSection.PROFILES, {})
155
153
 
156
154
  def has_profile(self, profile_name: str) -> bool:
157
155
  """Checks if a specific profile key exists in the YAML config."""
158
- return profile_name in self.config.get('PROFILES', {})
156
+ return profile_name in self.get_profile_list()
157
+
158
+ def resolve_profile_group(self, group_name: str) -> List[str]:
159
+ """Resolve a profile group into its ordered list of profile names.
160
+
161
+ Args:
162
+ group_name: Name of the PROFILE_GROUPS entry to resolve.
163
+
164
+ Returns:
165
+ Profile names in the order defined by the configuration.
166
+
167
+ Raises:
168
+ ValueError: If the group does not exist, is empty, or references
169
+ profiles that are not defined in PROFILES.
170
+ """
171
+ profile_groups = self.get_group_list()
172
+
173
+ if group_name not in profile_groups:
174
+ available = list(profile_groups.keys())
175
+ matches = difflib.get_close_matches(group_name, available, n=1, cutoff=0.6)
176
+ hint = f"\n Did you mean '{matches[0]}'?" if matches else ""
177
+
178
+ if available:
179
+ available_text = "\n - ".join(available)
180
+ available_message = f"\nAvailable profile groups:\n - {available_text}"
181
+ else:
182
+ available_message = "\nNo profile groups are configured."
183
+
184
+ raise ValueError(
185
+ f"\nProfile Group '{group_name}' not found.{hint}"
186
+ f"{available_message}\n"
187
+ )
188
+
189
+ profiles = list(profile_groups[group_name])
190
+ if not profiles:
191
+ raise ValueError(
192
+ f"Profile Group '{group_name}' does not contain any profiles."
193
+ )
194
+
195
+ configured_profiles = self.get_profile_list()
196
+ missing_profiles = [
197
+ profile_name
198
+ for profile_name in profiles
199
+ if profile_name not in configured_profiles
200
+ ]
201
+
202
+ if missing_profiles:
203
+ missing_text = "\n - ".join(missing_profiles)
204
+ raise ValueError(
205
+ f"Profile Group '{group_name}' references undefined profiles:"
206
+ f"\n - {missing_text}\n"
207
+ )
208
+
209
+ return profiles
159
210
 
160
211
  def describe(self, profile_name: str) -> str:
161
212
  """Generates a Markdown description of the workflow for a given profile."""
@@ -172,7 +223,14 @@ class BuildPlanner:
172
223
  self.build_state = build_state
173
224
  self.logger = get_logger()
174
225
 
175
- def _is_step_outdated(self, command: Dict, force_rebuild: bool = False) -> Tuple[UpdateCode, str]:
226
+ @staticmethod
227
+ def _normalize_path(path: str) -> str:
228
+ """Return a stable absolute path for DAG input/output comparison."""
229
+ return os.path.normcase(os.path.abspath(os.path.normpath(os.fspath(path))))
230
+
231
+ def _is_step_outdated(
232
+ self, command: Dict, produced_outputs: set[str], force_rebuild: bool = False
233
+ ) -> Tuple[UpdateCode, str]:
176
234
  # 1. Force Check (Short Circuit)
177
235
  if force_rebuild:
178
236
  self.logger.debug(f" - RESULT: Rebuild forced by user. (COMMAND_CHANGED)")
@@ -228,7 +286,18 @@ class BuildPlanner:
228
286
  for input_file in command['input_files']:
229
287
  self.logger.debug(f" - Checking input: '{input_file}'")
230
288
  if not os.path.exists(input_file):
231
- self.logger.debug(f" - RESULT: Input file does not exist. (MISSING_INPUT)")
289
+ normalized_input = self._normalize_path(input_file)
290
+ if normalized_input in produced_outputs:
291
+ self.logger.debug(
292
+ " - RESULT: Input does not exist yet, but is produced by "
293
+ "another step in this DAG."
294
+ )
295
+ continue
296
+
297
+ self.logger.debug(
298
+ " - RESULT: Input file does not exist and is not produced by "
299
+ "this DAG. (MISSING_INPUT)"
300
+ )
232
301
  raise FileNotFoundError(input_file)
233
302
 
234
303
  input_mtime = os.path.getmtime(input_file)
@@ -255,11 +324,25 @@ class BuildPlanner:
255
324
  for node, cmd in command_map.items():
256
325
  cmd['node_name'] = node
257
326
 
327
+ produced_outputs = {
328
+ self._normalize_path(command["output"])
329
+ for command in command_map.values()
330
+ }
331
+
258
332
  initially_outdated = {}
259
333
  build_order = list(nx.topological_sort(execution_graph))
260
334
  for node_name in build_order:
261
335
  command = command_map[node_name]
262
- update_code, context = self._is_step_outdated(command, force_rebuild=force_rebuild)
336
+ update_code, context = self._is_step_outdated(
337
+ command, produced_outputs, force_rebuild=force_rebuild
338
+ )
339
+
340
+ if update_code == UpdateCode.MISSING_INPUT:
341
+ raise FileNotFoundError(
342
+ f"Cannot build step '{node_name}': required input '{context}' is missing "
343
+ "and no step in the current build DAG creates it."
344
+ )
345
+
263
346
  if update_code != UpdateCode.UP_TO_DATE:
264
347
  initially_outdated[node_name] = (update_code, context)
265
348
 
@@ -305,13 +388,14 @@ class BuildPlanner:
305
388
 
306
389
  graph_manager = DependencyGraph(self.config.get("WORKFLOW", {}))
307
390
  execution_graph = graph_manager.get_execution_subgraph(final_step_name)
308
- general_config = self.config.get("GENERAL", {})
309
- command_gen = CommandGenerator(general_config, profile_config)
391
+ general_config = self.config.get(YAMLSection.GENERAL, {})
392
+ parameters_config = self.config.get(YAMLSection.PARAMETERS, {})
393
+ command_gen = CommandGenerator(parameters_config, profile_config)
310
394
  context = {"profile_name": profile_name, **general_config, **profile_config}
311
395
 
312
396
  # Pre-process input files to create full paths automatically.
313
- input_dir = context.get("INPUT_DIRECTORY")
314
397
  input_basenames = context.get("INPUT_FILES")
398
+ input_dir = context.get("INPUT_DIRECTORY", {})
315
399
 
316
400
  if input_dir and input_basenames:
317
401
  full_paths = [os.path.join(input_dir, f) for f in input_basenames]
@@ -321,7 +405,6 @@ class BuildPlanner:
321
405
  for node_name in nx.topological_sort(execution_graph):
322
406
  node_data = execution_graph.nodes[node_name]
323
407
 
324
- # --- IMPROVED ERROR HANDLING ---
325
408
  try:
326
409
  command_map[node_name] = command_gen.generate_for_node(
327
410
  node_name, node_data, context, resolved_outputs
@@ -365,8 +448,11 @@ class BuildExecutor:
365
448
  if status_callback:
366
449
  status_callback("step", 0, total_to_run, "started")
367
450
 
368
- for step in plan.steps_to_skip:
369
- logger.log(f"Skipping '{step.node_name}' (up-to-date)")
451
+ if plan.steps_to_skip:
452
+ skipped = ", ".join(
453
+ step.node_name for step in plan.steps_to_skip
454
+ )
455
+ logger.log(f"⏭️ Skipping up-to-date steps:\n {skipped}\n")
370
456
 
371
457
  if not plan.steps_to_run:
372
458
  if status_callback:
@@ -391,7 +477,7 @@ class BuildExecutor:
391
477
  if not tasks_this_generation:
392
478
  continue
393
479
 
394
- max_workers = self.config.get("GENERAL", {}).get("MAX_WORKERS")
480
+ max_workers = self.config.get(YAMLSection.GENERAL, {}).get("MAX_WORKERS")
395
481
  with ProcessPoolExecutor(
396
482
  max_workers=max_workers, initializer=initializer, initargs=initargs
397
483
  ) as executor:
@@ -407,7 +493,7 @@ class BuildExecutor:
407
493
 
408
494
  if status == 'EXECUTED':
409
495
  finished_count += 1
410
- logger.log(f"✅ Finished step '{step_name}' [{finished_count}/{total_to_run}]")
496
+ logger.log(f"✅ Finished step '{step_name}' [{finished_count}/{total_to_run}]\n")
411
497
  if status_callback:
412
498
  status_callback("step", finished_count, total_to_run, "done")
413
499
  self.build_state[result_data['output_path']] = {
@@ -453,7 +539,7 @@ class BuildExecutor:
453
539
 
454
540
  # Log the command (Truncated)
455
541
  cmd_display = _truncate(command['cmd_string'])
456
- logger.log(f"\n▶️ Running step '{step_name}': {update_text}")
542
+ logger.log(f"\n\n▶️ Running step '{step_name}': {update_text}")
457
543
  logger.log(f" [{step_name}] {cmd_display}")
458
544
 
459
545
  start_time = time.perf_counter()
@@ -559,7 +645,8 @@ class BuildReporter:
559
645
  """Generates a full Markdown report for the workflow."""
560
646
  import datetime
561
647
  timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
562
- project_name = self.config.get("GENERAL", {}).get("PROJECT_NAME", "LiteBuild Project")
648
+ project_cfg = self.config.get(YAMLSection.PROJECT)
649
+ project_name = project_cfg.get("PROJECT_NAME", "LiteBuild Project")
563
650
 
564
651
  # 1. Generate the Plan to resolve all variables
565
652
  planner = BuildPlanner(self.config, {})
@@ -588,8 +675,9 @@ class BuildReporter:
588
675
  ]
589
676
 
590
677
  # --- OVERVIEW SECTION ---
591
- # Checks for the global OVERVIEW key in the config
592
- overview_text = self.config.get("OVERVIEW")
678
+ # Checks for the OVERVIEW key in the config
679
+
680
+ overview_text = project_cfg.get("OVERVIEW")
593
681
  if overview_text:
594
682
  lines.append("## Overview")
595
683
  lines.append(overview_text.strip())
@@ -47,7 +47,6 @@ class BuildLogger:
47
47
  self.log_file_handle = open(log_file, 'a', encoding='utf-8')
48
48
  self.lock = FileLock(log_file.with_suffix(".lock"))
49
49
 
50
- # --- FIX: Duck typing check instead of strict isinstance(TextIO) ---
51
50
  elif hasattr(output, 'write') and hasattr(output, 'flush'):
52
51
  self.log_file_handle = output
53
52
  self.lock = nullcontext()
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Final
5
+
6
+
7
+ class StatusContext:
8
+ """Defined status-update context values."""
9
+ PROFILE: Final[str] = "profile"
10
+ STEP: Final[str] = "step"
11
+
12
+
13
+ class StatusCode:
14
+ """Defined status-update state values."""
15
+ STARTED: Final[str] = "started"
16
+ DONE: Final[str] = "done"
17
+ SKIPPED: Final[str] = "skipped"
18
+ ERROR: Final[str] = "error"
19
+
20
+ @dataclass(frozen=True)
21
+ class StatusMessage:
22
+ context_type: str
23
+ current_task: int
24
+ total: int
25
+ status_code: str
26
+
27
+ @classmethod
28
+ def profile(cls, current_task: int, total: int, status_code: str) -> "StatusMessage":
29
+ return cls(
30
+ context_type=StatusContext.PROFILE,
31
+ current_task=current_task,
32
+ total=total,
33
+ status_code=status_code,
34
+ )
35
+
36
+ @classmethod
37
+ def step(cls, current_task: int, total: int, status_code: str) -> "StatusMessage":
38
+ return cls(
39
+ context_type=StatusContext.STEP,
40
+ current_task=current_task,
41
+ total=total,
42
+ status_code=status_code,
43
+ )
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import threading
5
+ import time
6
+ import traceback
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ from LiteBuild.build_engine import BuildEngine
11
+ from LiteBuild.build_logger import BuildLogger, LogLevel
12
+ from LiteBuild.schema import YAMLSection
13
+ from PySide6.QtCore import QObject, Signal
14
+
15
+ from LiteBuild.build_util import StatusCode, StatusMessage
16
+
17
+
18
+ # build_workers.py
19
+
20
+ class BuildWorker(QObject):
21
+ """
22
+ Worker for running a single build profile or step.
23
+ """
24
+
25
+ finished = Signal()
26
+ error = Signal(Exception)
27
+ log_message = Signal(str)
28
+ status_signal = Signal(object)
29
+
30
+ def __init__(
31
+ self,
32
+ config_path: str,
33
+ profile_name: str,
34
+ cli_vars: Optional[Dict] = None,
35
+ step_name: Optional[str] = None,
36
+ ) -> None:
37
+ """Initialize the worker.
38
+
39
+ Args:
40
+ config_path: Path to the LiteBuild config file.
41
+ profile_name: Profile to execute.
42
+ cli_vars: Optional CLI variable overrides.
43
+ step_name: Optional explicit workflow step to execute.
44
+ """
45
+ super().__init__()
46
+ self.config_path = config_path
47
+ self.profile_name = profile_name
48
+ self.cli_vars = cli_vars
49
+ self.step_name = step_name
50
+
51
+ def _emit_status(self, message: StatusMessage) -> None:
52
+ """Emit a structured status update.
53
+
54
+ Args:
55
+ message: Status payload to emit.
56
+ """
57
+ self.status_signal.emit(message)
58
+
59
+ def _relay_step_status(
60
+ self,
61
+ context_type: str,
62
+ current_task: int,
63
+ total: int,
64
+ status_code: str,
65
+ ) -> None:
66
+ """Relay engine status callback to the Qt signal.
67
+
68
+ Args:
69
+ context_type: Status context type.
70
+ current_task: Current step/profile index.
71
+ total: Total number of items in the context.
72
+ status_code: Status code such as started/done/error.
73
+ """
74
+ message = (
75
+ StatusMessage.profile(current_task, total, status_code)
76
+ if context_type == "profile"
77
+ else StatusMessage.step(current_task, total, status_code)
78
+ )
79
+ self._emit_status(message)
80
+
81
+ def run(self) -> None:
82
+ """Run the build and tail log output."""
83
+ log_dir = Path("build/logs")
84
+ log_dir.mkdir(parents=True, exist_ok=True)
85
+ log_file = log_dir / f"litebuild_{self.profile_name}_{int(time.time())}.log"
86
+
87
+ log_level_enum = LogLevel["INFO"]
88
+ logger = BuildLogger(log_file, log_level=log_level_enum)
89
+
90
+ try:
91
+ engine = BuildEngine.from_file(self.config_path, cli_vars=self.cli_vars)
92
+ project_cfg = engine.config.get(YAMLSection.PROJECT)
93
+
94
+ final_step_name = self.step_name or project_cfg.get("DEFAULT_WORKFLOW_STEP")
95
+ if not final_step_name:
96
+ raise ValueError(
97
+ "No workflow target step specified. Please "
98
+ "set a DEFAULT_WORKFLOW_STEP in PROJECT: section of your configuration file."
99
+ )
100
+
101
+ self._emit_status(StatusMessage.profile(1, 1, StatusCode.STARTED))
102
+
103
+ build_thread = threading.Thread(
104
+ target=engine.execute,
105
+ args=(final_step_name, self.profile_name, logger),
106
+ kwargs={"status_callback": self._relay_step_status},
107
+ )
108
+ build_thread.start()
109
+
110
+ last_pos = 0
111
+ time.sleep(0.2)
112
+
113
+ if log_file.exists():
114
+ with open(log_file, "r", encoding="utf-8") as file_obj:
115
+ while build_thread.is_alive():
116
+ lines = file_obj.readlines()
117
+ if lines:
118
+ for line in lines:
119
+ self.log_message.emit(line.strip())
120
+ last_pos = file_obj.tell()
121
+ time.sleep(0.1)
122
+
123
+ build_thread.join()
124
+
125
+ if log_file.exists():
126
+ with open(log_file, "r", encoding="utf-8") as file_obj:
127
+ file_obj.seek(last_pos)
128
+ for line in file_obj.readlines():
129
+ self.log_message.emit(line.strip())
130
+
131
+ self._emit_status(StatusMessage.profile(1, 1, StatusCode.DONE))
132
+ self.log_message.emit("DONE")
133
+ self.finished.emit()
134
+
135
+ except Exception as exc:
136
+ tb_str = traceback.format_exc()
137
+ self.log_message.emit("\n❌ A critical error occurred.")
138
+ self.log_message.emit(tb_str)
139
+ self.error.emit(exc)
140
+ self._emit_status(StatusMessage.profile(1, 1, StatusCode.ERROR))
141
+
142
+
143
+ class BuildGroupWorker(QObject):
144
+ """
145
+ Worker for running a GROUP of profiles sequentially.
146
+ """
147
+
148
+ finished = Signal()
149
+ error = Signal(Exception)
150
+ log_message = Signal(str)
151
+ status_signal = Signal(object)
152
+
153
+ def __init__(
154
+ self,
155
+ config_path: str,
156
+ group_name: str,
157
+ cli_vars: Optional[Dict] = None,
158
+ ) -> None:
159
+ """Initialize the worker.
160
+
161
+ Args:
162
+ config_path: Path to the LiteBuild config file.
163
+ group_name: Profile group to execute.
164
+ cli_vars: Optional CLI variable overrides.
165
+ """
166
+ super().__init__()
167
+ self.config_path = config_path
168
+ self.group_name = group_name
169
+ self.cli_vars = cli_vars
170
+
171
+ def _emit_status(self, message: StatusMessage) -> None:
172
+ """Emit a structured status update.
173
+
174
+ Args:
175
+ message: Status payload to emit.
176
+ """
177
+ self.status_signal.emit(message)
178
+
179
+ def _relay_step_status(
180
+ self,
181
+ context_type: str,
182
+ current_task: int,
183
+ total: int,
184
+ status_code: str,
185
+ ) -> None:
186
+ """Relay engine status callback to the GUI.
187
+
188
+ Args:
189
+ context_type: Status context type.
190
+ current_task: Current step/profile index.
191
+ total: Total number of items in the context.
192
+ status_code: Status code such as started/done/error.
193
+ """
194
+ message = (
195
+ StatusMessage.profile(current_task, total, status_code)
196
+ if context_type == "profile"
197
+ else StatusMessage.step(current_task, total, status_code)
198
+ )
199
+ self._emit_status(message)
200
+
201
+ @staticmethod
202
+ def get_suggestion(invalid_key: str, valid_options: list[str]) -> str:
203
+ """Return a 'Did you mean X?' hint if a close match is found.
204
+
205
+ Args:
206
+ invalid_key: Invalid user-provided key.
207
+ valid_options: Valid available options.
208
+
209
+ Returns:
210
+ Suggestion text or an empty string.
211
+ """
212
+ matches = difflib.get_close_matches(invalid_key, valid_options, n=1, cutoff=0.6)
213
+ return f"\n Did you mean '{matches[0]}'?" if matches else ""
214
+
215
+ def run(self) -> None:
216
+ """Iterate through profiles and execute them sequentially."""
217
+ try:
218
+ engine = BuildEngine.from_file(self.config_path, cli_vars=self.cli_vars)
219
+
220
+ profile_groups = engine.config.get(YAMLSection.PROFILE_GROUPS, {})
221
+ if self.group_name not in profile_groups:
222
+ available = list(profile_groups.keys())
223
+ hint = self.get_suggestion(self.group_name, available)
224
+ raise ValueError(
225
+ f"Profile Group '{self.group_name}' not found. {hint}\n"
226
+ f"Available: {available}"
227
+ )
228
+
229
+ profiles_to_run = profile_groups[self.group_name]
230
+ total_profiles = len(profiles_to_run)
231
+
232
+ self.log_message.emit(f"Starting Profile Group: {self.group_name} ")
233
+ self.log_message.emit(f"Profiles to run: {', '.join(profiles_to_run)} ")
234
+
235
+ for index, profile_name in enumerate(profiles_to_run, start=1):
236
+ self._emit_status(StatusMessage.profile(index, total_profiles, StatusCode.STARTED))
237
+
238
+ self.log_message.emit("\n" + "=" * 80)
239
+ self.log_message.emit(
240
+ f" ({index}/{total_profiles}) Running Profile: {profile_name}\n "
241
+ )
242
+
243
+ log_dir = Path("build/logs")
244
+ log_dir.mkdir(parents=True, exist_ok=True)
245
+ log_file = (
246
+ log_dir / f"litebuild_{self.group_name}_{profile_name}_{int(time.time())}.log"
247
+ )
248
+
249
+ logger = BuildLogger(log_file, log_level=LogLevel["INFO"])
250
+
251
+ profile_engine = BuildEngine.from_file(self.config_path, cli_vars=self.cli_vars)
252
+ step_name = profile_engine.config.get("DEFAULT_WORKFLOW_STEP")
253
+ if not step_name:
254
+ raise ValueError(
255
+ "No DEFAULT_WORKFLOW_STEP found in config for group execution."
256
+ )
257
+
258
+ build_thread = threading.Thread(
259
+ target=profile_engine.execute,
260
+ args=(step_name, profile_name, logger),
261
+ kwargs={"status_callback": self._relay_step_status},
262
+ )
263
+ build_thread.start()
264
+
265
+ last_pos = 0
266
+ time.sleep(0.2)
267
+
268
+ while build_thread.is_alive():
269
+ if log_file.exists():
270
+ with open(log_file, "r", encoding="utf-8") as file_obj:
271
+ file_obj.seek(last_pos)
272
+ for line in file_obj.readlines():
273
+ self.log_message.emit(line.strip())
274
+ last_pos = file_obj.tell()
275
+ time.sleep(0.2)
276
+
277
+ build_thread.join()
278
+
279
+ if log_file.exists():
280
+ with open(log_file, "r", encoding="utf-8") as file_obj:
281
+ file_obj.seek(last_pos)
282
+ for line in file_obj.readlines():
283
+ self.log_message.emit(line.strip())
284
+
285
+ self.log_message.emit(f"✅ Profile '{profile_name}' finished.")
286
+ self._emit_status(StatusMessage.profile(index, total_profiles, StatusCode.DONE))
287
+
288
+ self.log_message.emit("\n" + "=" * 80)
289
+ self.log_message.emit(
290
+ f"✅ Profile Group '{self.group_name}' finished successfully. "
291
+ )
292
+ self.finished.emit()
293
+
294
+ except Exception as exc:
295
+ tb_str = traceback.format_exc()
296
+ self.log_message.emit("\n❌ A critical error occurred during the group build.")
297
+ self.log_message.emit(tb_str)
298
+ self.error.emit(exc)
299
+ self._emit_status(StatusMessage.profile(0, 0, StatusCode.ERROR))