LiteBuild 0.4.1__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 (30) hide show
  1. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/build_engine.py +322 -77
  2. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/build_logger.py +1 -2
  3. litebuild-0.7/LiteBuild/build_util.py +43 -0
  4. litebuild-0.7/LiteBuild/build_workers.py +299 -0
  5. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/command_generator.py +142 -92
  6. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/dependency_graph.py +8 -1
  7. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/lite_build_controller.py +11 -5
  8. litebuild-0.7/LiteBuild/lite_build_runner.py +305 -0
  9. litebuild-0.7/LiteBuild/litebuild.py +274 -0
  10. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/schema.py +55 -24
  11. litebuild-0.7/LiteBuild/sleep_inhibitor.py +57 -0
  12. litebuild-0.7/LiteBuild.egg-info/PKG-INFO +273 -0
  13. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/SOURCES.txt +2 -0
  14. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/entry_points.txt +1 -0
  15. litebuild-0.7/PKG-INFO +273 -0
  16. litebuild-0.7/docs/readme.md +246 -0
  17. {litebuild-0.4.1 → litebuild-0.7}/pyproject.toml +2 -1
  18. litebuild-0.4.1/LiteBuild/build_workers.py +0 -176
  19. litebuild-0.4.1/LiteBuild/lite_build_runner.py +0 -187
  20. litebuild-0.4.1/LiteBuild/litebuild.py +0 -129
  21. litebuild-0.4.1/LiteBuild.egg-info/PKG-INFO +0 -108
  22. litebuild-0.4.1/PKG-INFO +0 -108
  23. litebuild-0.4.1/docs/readme.md +0 -81
  24. {litebuild-0.4.1 → litebuild-0.7}/LICENSE +0 -0
  25. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/config_loader.py +0 -0
  26. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/dependency_links.txt +0 -0
  27. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/requires.txt +0 -0
  28. {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/top_level.txt +0 -0
  29. {litebuild-0.4.1 → litebuild-0.7}/setup.cfg +0 -0
  30. {litebuild-0.4.1 → litebuild-0.7}/tests/test_build_engine.py +0 -0
@@ -1,24 +1,29 @@
1
- # build_engine.py
2
1
  from concurrent.futures import ProcessPoolExecutor
3
- from enum import IntEnum
2
+ import datetime
3
+ import difflib
4
+ from enum import IntEnum, StrEnum
4
5
  import json
5
6
  import os
6
7
  from pathlib import Path
7
8
  import subprocess
8
9
  import time
10
+ import traceback
9
11
  from typing import List, Dict, Tuple, NamedTuple, Optional
10
12
 
11
13
  import networkx as nx
12
14
  from YMLEditor.yaml_reader import ConfigLoader
13
15
 
16
+
17
+ # build_engine.py
18
+
14
19
  from LiteBuild.build_logger import BuildLogger, setup_logger, get_logger
15
20
  from LiteBuild.command_generator import CommandGenerator
16
21
  from LiteBuild.dependency_graph import DependencyGraph
17
- from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator
22
+ from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator, YAMLSection
18
23
 
19
24
 
20
25
  class UpdateCode(IntEnum):
21
- """Enumeration for why a build step is considered outdated."""
26
+ """Enumeration for why a build step is outdated."""
22
27
  UP_TO_DATE = 0
23
28
  MISSING_OUTPUT = 1
24
29
  NOT_TRACKED = 2
@@ -28,6 +33,7 @@ class UpdateCode(IntEnum):
28
33
  NEWER_INPUT = 6
29
34
  MISSING_INPUT = 7
30
35
  STALE_TARGET = 8
36
+ FORCED = 9
31
37
 
32
38
 
33
39
  class BuildStep(NamedTuple):
@@ -55,13 +61,28 @@ class BuildEngine:
55
61
  ):
56
62
  """Initializes the BuildEngine, merging command-line variables into the config."""
57
63
  if cli_vars:
58
- if "GENERAL" not in config_data:
59
- config_data["GENERAL"] = {}
60
- 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)
61
67
 
62
68
  self.config = config_data
63
69
  self.state_file = state_file
64
70
 
71
+ # Validate Input Directory
72
+ input_dir = config_data.get(YAMLSection.GENERAL, {}).get("INPUT_DIRECTORY")
73
+ if input_dir:
74
+ path = Path(input_dir)
75
+ if not path.exists():
76
+ raise FileNotFoundError(
77
+ f"❌ Configuration Error: INPUT_DIRECTORY does not exist.\n"
78
+ f" Path: {path.absolute()}"
79
+ )
80
+ if not path.is_dir():
81
+ raise NotADirectoryError(
82
+ f"❌ Configuration Error: INPUT_DIRECTORY is not a directory.\n"
83
+ f" Path: {path.absolute()}"
84
+ )
85
+
65
86
  @classmethod
66
87
  def from_file(
67
88
  cls, config_filepath: str, cli_vars: Optional[Dict] = None,
@@ -76,9 +97,9 @@ class BuildEngine:
76
97
  return cls(config_data, cli_vars=cli_vars, state_file=state_file)
77
98
  except (FileNotFoundError, ValueError) as e:
78
99
  # Re-raise to be handled by the calling script (CLI or GUI)
79
- raise e
100
+ raise
80
101
 
81
- def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None):
102
+ def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None, status_callback=None, force_rebuild=False):
82
103
  """
83
104
  Plans and executes the build for a specific workflow entry step.
84
105
  """
@@ -86,47 +107,112 @@ class BuildEngine:
86
107
  logger = get_logger()
87
108
  setup_logger(logger)
88
109
 
89
- # --- Extract Context info ---
90
- general_cfg = self.config.get("GENERAL", {})
91
- profile_cfg = self.config.get("PROFILES", {}).get(profile_name, {})
92
-
93
- # Look in General (CLI vars usually land here) or Profile
94
- segment = general_cfg.get("SEGMENT") or profile_cfg.get("SEGMENT") or "Unknown"
95
- category = general_cfg.get("CATEGORY") or profile_cfg.get("CATEGORY") or "Unknown"
96
-
97
- context_str = f"Segment: {segment}, Category: {category}"
110
+ context_str = ""
98
111
  if profile_name:
99
- context_str += f" (Profile: {profile_name})"
112
+ context_str += f" Profile: {profile_name}"
100
113
 
101
- logger.log(f"🔵 Executing build for step {final_step_name}")
102
- logger.log(f"ℹ️ {context_str}\n")
114
+ # Format the time as a string (HH:MM:SS) using strftime()
115
+ current_time = "" #now.strftime("%H:%M:%S")
116
+
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")
103
120
 
104
121
  try:
105
122
  state_manager = BuildStateManager(self.state_file)
106
123
  planner = BuildPlanner(self.config, state_manager.load_state())
107
- plan = planner.plan_build(profile_name, final_step_name)
108
-
124
+ plan = planner.plan_build(profile_name, final_step_name, force_rebuild=force_rebuild)
109
125
  executor = BuildExecutor(state_manager, self.config)
110
- success = executor.execute_plan(plan, logger)
126
+ success = executor.execute_plan(plan, logger, status_callback=status_callback)
111
127
  except Exception as e:
112
128
  success = False
113
- logger.log(f"{e}")
129
+ # --- ERROR REPORTING ---
130
+ logger.log(f"🔴{e}")
131
+
132
+ # Update status callback if present
133
+ if status_callback:
134
+ status_callback("step", 0, 0, "error")
135
+
136
+ # Format the time as a string (HH:MM:SS) using strftime()
137
+ current_time = "" #now.strftime("%H:%M:%S")
114
138
 
115
139
  if success:
116
- logger.log(f"\n✅ Build finished successfully.")
140
+ logger.log(f"\n✅ Build finished successfully. {current_time}")
141
+ return True
117
142
  else:
118
- logger.log(f"🔴Build failed for {final_step_name}")
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, {})
119
153
 
120
154
  def has_profile(self, profile_name: str) -> bool:
121
155
  """Checks if a specific profile key exists in the YAML config."""
122
- 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
123
210
 
124
211
  def describe(self, profile_name: str) -> str:
125
212
  """Generates a Markdown description of the workflow for a given profile."""
126
213
  reporter = BuildReporter(self.config)
127
214
  return reporter.describe_workflow(profile_name)
128
215
 
129
-
130
216
  class BuildPlanner:
131
217
  """
132
218
  Analyzes the workflow and build state to create an incremental build plan.
@@ -137,10 +223,22 @@ class BuildPlanner:
137
223
  self.build_state = build_state
138
224
  self.logger = get_logger()
139
225
 
140
- def _is_step_outdated(self, command: Dict) -> 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]:
234
+ # 1. Force Check (Short Circuit)
235
+ if force_rebuild:
236
+ self.logger.debug(f" - RESULT: Rebuild forced by user. (COMMAND_CHANGED)")
237
+ return UpdateCode.FORCED, ""
238
+
141
239
  """Checks a single step to see if it needs to be rebuilt, with detailed debug logging."""
142
240
  output_path = command['output']
143
- node_name = command.get('node_name', 'UnknownStep') # Assuming node_name is passed in command dict
241
+ node_name = command.get('node_name', 'UnknownStep')
144
242
 
145
243
  self.logger.debug(f"\n--- Checking status of step '{node_name}' ---")
146
244
  self.logger.debug(f" - Output file: '{output_path}'")
@@ -188,7 +286,18 @@ class BuildPlanner:
188
286
  for input_file in command['input_files']:
189
287
  self.logger.debug(f" - Checking input: '{input_file}'")
190
288
  if not os.path.exists(input_file):
191
- 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
+ )
192
301
  raise FileNotFoundError(input_file)
193
302
 
194
303
  input_mtime = os.path.getmtime(input_file)
@@ -207,7 +316,7 @@ class BuildPlanner:
207
316
  self.logger.debug(f" - RESULT: Step is up-to-date. (UP_TO_DATE)")
208
317
  return UpdateCode.UP_TO_DATE, ""
209
318
 
210
- def plan_build(self, profile_name: str, final_step_name: str = None) -> BuildPlan:
319
+ def plan_build(self, profile_name: str, final_step_name: str = None, force_rebuild: bool = False) -> BuildPlan:
211
320
  command_map, execution_graph = self._generate_command_map_and_graph(
212
321
  profile_name, final_step_name
213
322
  )
@@ -215,11 +324,25 @@ class BuildPlanner:
215
324
  for node, cmd in command_map.items():
216
325
  cmd['node_name'] = node
217
326
 
327
+ produced_outputs = {
328
+ self._normalize_path(command["output"])
329
+ for command in command_map.values()
330
+ }
331
+
218
332
  initially_outdated = {}
219
333
  build_order = list(nx.topological_sort(execution_graph))
220
334
  for node_name in build_order:
221
335
  command = command_map[node_name]
222
- update_code, context = self._is_step_outdated(command)
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
+
223
346
  if update_code != UpdateCode.UP_TO_DATE:
224
347
  initially_outdated[node_name] = (update_code, context)
225
348
 
@@ -239,6 +362,14 @@ class BuildPlanner:
239
362
  steps_to_skip.append(BuildStep(node_name, step_command, UpdateCode.UP_TO_DATE, ""))
240
363
  return BuildPlan(steps_to_run, steps_to_skip, command_map, execution_graph)
241
364
 
365
+ @staticmethod
366
+ def get_suggestion(invalid_key: str, valid_options: list[str]) -> str:
367
+ """Returns a 'Did you mean X?' string if a close match is found."""
368
+ matches = difflib.get_close_matches(invalid_key, valid_options, n=1, cutoff=0.6)
369
+ if matches:
370
+ return f"\n Did you mean '{matches[0]}'?"
371
+ return ""
372
+
242
373
  def _generate_command_map_and_graph(self, profile_name: str, final_step_name: str = None) -> \
243
374
  Tuple[Dict, nx.DiGraph]:
244
375
  """Generates all commands and the dependency graph for a given profile."""
@@ -249,33 +380,40 @@ class BuildPlanner:
249
380
  profile_config = all_profiles[profile_name]
250
381
  else:
251
382
  available = "\n - ".join(all_profiles.keys())
383
+ hint = self.get_suggestion(profile_name, list(all_profiles.keys()))
252
384
  raise ValueError(
253
- f"profile '{profile_name}' not found. Available profiles are:\n - {available}"
385
+ f"\nProfile '{profile_name}' not found.{hint}\n"
386
+ f"\nAvailable profiles:\n - {available}\n"
254
387
  )
255
388
 
256
389
  graph_manager = DependencyGraph(self.config.get("WORKFLOW", {}))
257
390
  execution_graph = graph_manager.get_execution_subgraph(final_step_name)
258
- general_config = self.config.get("GENERAL", {})
259
- 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)
260
394
  context = {"profile_name": profile_name, **general_config, **profile_config}
261
395
 
262
396
  # Pre-process input files to create full paths automatically.
263
- input_dir = context.get("INPUT_DIRECTORY")
264
397
  input_basenames = context.get("INPUT_FILES")
398
+ input_dir = context.get("INPUT_DIRECTORY", {})
265
399
 
266
400
  if input_dir and input_basenames:
267
- # Create the list of full paths
268
401
  full_paths = [os.path.join(input_dir, f) for f in input_basenames]
269
- # Overwrite the INPUT_FILES in the context with the full paths.
270
- # This makes the fully resolved list available to all template substitutions.
271
402
  context['INPUT_FILES'] = full_paths
272
403
 
273
404
  command_map, resolved_outputs = {}, {}
274
405
  for node_name in nx.topological_sort(execution_graph):
275
406
  node_data = execution_graph.nodes[node_name]
276
- command_map[node_name] = command_gen.generate_for_node(
277
- node_name, node_data, context, resolved_outputs
278
- )
407
+
408
+ try:
409
+ command_map[node_name] = command_gen.generate_for_node(
410
+ node_name, node_data, context, resolved_outputs
411
+ )
412
+ except Exception as e:
413
+ # This catches errors in CommandGenerator (like invalid format strings)
414
+ # and adds the context of WHICH rule failed.
415
+ raise RuntimeError(f"Error generating COMMAND for rule '{node_name}': {e}") from e
416
+
279
417
  return command_map, execution_graph
280
418
 
281
419
  class BuildExecutor:
@@ -286,33 +424,46 @@ class BuildExecutor:
286
424
  self.build_state = state_manager.load_state()
287
425
  self.config = config
288
426
  self.update_codes = {
289
- UpdateCode.UP_TO_DATE: "(Up-to-date)", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
290
- UpdateCode.NOT_TRACKED: "(First build)",
291
- UpdateCode.COMMAND_CHANGED: "(Command has changed)",
292
- UpdateCode.INPUTS_CHANGED: "(Input file list has changed)",
293
- UpdateCode.PARAMS_CHANGED: "(Parameters have changed)",
294
- UpdateCode.NEWER_INPUT: "(Input '{context}' is newer)",
295
- UpdateCode.MISSING_INPUT: "(Input '{context}' is missing)",
296
- UpdateCode.STALE_TARGET: "(Target is stale)"
427
+ UpdateCode.UP_TO_DATE: "", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
428
+ UpdateCode.NOT_TRACKED: "(first build)",
429
+ UpdateCode.COMMAND_CHANGED: "(command has changed)",
430
+ UpdateCode.INPUTS_CHANGED: "(input file list has changed)",
431
+ UpdateCode.PARAMS_CHANGED: "(parameters have changed)",
432
+ UpdateCode.NEWER_INPUT: "(input '{context}' is newer)",
433
+ UpdateCode.MISSING_INPUT: "(input '{context}' is missing)",
434
+ UpdateCode.STALE_TARGET: "(stale target)",
435
+ UpdateCode.FORCED: "(forced)"
297
436
  }
298
437
 
299
- def execute_plan(self, plan: BuildPlan, logger: BuildLogger) -> bool:
438
+ def execute_plan(self, plan: BuildPlan, logger: BuildLogger, status_callback=None) -> bool:
300
439
  """Executes the build plan, managing parallel execution and state."""
301
440
  total_to_run = len(plan.steps_to_run)
302
441
  finished_count = 0
303
442
 
304
- for step in plan.steps_to_skip:
305
- logger.log(f"Skipping '{step.node_name}' (up-to-date)")
443
+ # Track timing for reporting
444
+ step_timings = {}
445
+ build_start_time = time.time()
446
+
447
+ # --- Initial Status Update ---
448
+ if status_callback:
449
+ status_callback("step", 0, total_to_run, "started")
450
+
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")
306
456
 
307
457
  if not plan.steps_to_run:
458
+ if status_callback:
459
+ status_callback("step", 0, 0, "done")
308
460
  return True
309
461
 
310
- # Ask the logger for the information needed to initialize workers.
311
- # This is polymorphic: a FileLogger provides info, a StreamLogger does not.
312
462
  worker_init_info = logger.get_worker_init_info()
313
463
  initializer, initargs = (worker_init_info if worker_init_info else (None, ()))
314
464
 
315
465
  tasks_to_run_map = {s.node_name: s for s in plan.steps_to_run}
466
+
316
467
  for generation in nx.topological_generations(plan.execution_graph):
317
468
  tasks_this_generation = []
318
469
  for node_name in generation:
@@ -326,7 +477,7 @@ class BuildExecutor:
326
477
  if not tasks_this_generation:
327
478
  continue
328
479
 
329
- max_workers = self.config.get("GENERAL", {}).get("MAX_WORKERS")
480
+ max_workers = self.config.get(YAMLSection.GENERAL, {}).get("MAX_WORKERS")
330
481
  with ProcessPoolExecutor(
331
482
  max_workers=max_workers, initializer=initializer, initargs=initargs
332
483
  ) as executor:
@@ -335,46 +486,91 @@ class BuildExecutor:
335
486
  halt_build = False
336
487
  for status, result_data in results:
337
488
  step_name = result_data.get('step_name', 'N/A')
489
+
490
+ # Capture timing if available
491
+ if 'elapsed_time' in result_data:
492
+ step_timings[step_name] = result_data['elapsed_time']
493
+
338
494
  if status == 'EXECUTED':
339
495
  finished_count += 1
340
- 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")
497
+ if status_callback:
498
+ status_callback("step", finished_count, total_to_run, "done")
341
499
  self.build_state[result_data['output_path']] = {
342
500
  "hashes": result_data['hashes'], "mtime": result_data['mtime']
343
501
  }
344
502
  elif status == 'FAILED':
345
503
  halt_build = True
346
504
  logger.log(f"🔺 Build failed for Step '{step_name}'")
505
+ if status_callback:
506
+ status_callback("step", finished_count, total_to_run, "error")
507
+
347
508
  if halt_build:
348
509
  self.state_manager.save_state(self.build_state)
349
510
  return False
511
+
350
512
  self.state_manager.save_state(self.build_state)
513
+
514
+ # --- TIMING REPORT ---
515
+ total_build_time = time.time() - build_start_time
516
+ self._print_timing_report(logger, step_timings, total_build_time)
517
+
351
518
  return True
352
519
 
353
520
  @staticmethod
354
521
  def _run_single_command(task: Tuple[str, Dict, str]) -> Tuple[str, Dict]:
355
- """Runs a command and streams all output to the configured log ."""
522
+ """Runs a command and streams all output to the configured log."""
356
523
  logger = get_logger()
357
524
  step_name, command, update_text = task
358
525
  output_path = command['output']
359
526
 
360
- logger.log(f"\n▶️ Running step '{step_name}': {update_text}")
361
- logger.log(f" [{step_name}] {command['cmd_string']}")
527
+ # --- Helper for log truncation ---
528
+ def _truncate(text: str, limit: int = 400) -> str:
529
+ """Keeps the start and end of long strings."""
530
+ if len(text) <= limit:
531
+ return text
532
+
533
+ # Keep first 40% and last 40% of the limit
534
+ keep = int(limit * 0.4)
535
+ omitted_count = len(text) - (keep * 2)
536
+
537
+ # Formatting: Clear brackets with internal spacing
538
+ return f"{text[:keep]} [ ... {omitted_count} chars truncated ... ] {text[-keep:]}"
539
+
540
+ # Log the command (Truncated)
541
+ cmd_display = _truncate(command['cmd_string'])
542
+ logger.log(f"\n\n▶️ Running step '{step_name}': {update_text}")
543
+ logger.log(f" [{step_name}] {cmd_display}")
544
+
545
+ start_time = time.perf_counter()
362
546
 
363
547
  try:
364
548
  process = subprocess.Popen(
365
549
  command['cmd_string'], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
366
550
  text=True, encoding='utf-8', errors='replace'
367
551
  )
552
+
368
553
  for line in iter(process.stdout.readline, ''):
369
- logger.log(f" [{step_name}] {line.strip()}")
554
+ clean_line = line.strip()
555
+ if clean_line:
556
+ # Also truncate massive output lines (rare but possible with WKT echos)
557
+ logger.log(f" [{step_name}] {_truncate(clean_line)}")
558
+
370
559
  return_code = process.wait()
560
+
561
+ end_time = time.perf_counter()
562
+ elapsed = end_time - start_time
563
+
371
564
  if return_code != 0:
372
565
  raise subprocess.CalledProcessError(return_code, "")
373
566
 
374
567
  new_mtime = os.path.getmtime(output_path)
375
568
  result_data = {
376
- 'step_name': step_name, 'output_path': output_path, 'hashes': command['hashes'],
377
- 'mtime': new_mtime
569
+ 'step_name': step_name,
570
+ 'output_path': output_path,
571
+ 'hashes': command['hashes'],
572
+ 'mtime': new_mtime,
573
+ 'elapsed_time': elapsed
378
574
  }
379
575
  return 'EXECUTED', result_data
380
576
  except Exception as e:
@@ -382,15 +578,46 @@ class BuildExecutor:
382
578
  result_data = {'step_name': step_name}
383
579
  return 'FAILED', result_data
384
580
 
581
+ def _print_timing_report(self, logger: BuildLogger, step_timings: Dict[str, float], total_time: float):
582
+ """Generates and logs the timing summary table."""
583
+ logger.log("\n🔵 Timing Report:")
584
+
585
+ # Sort by duration (Longest first)
586
+ sorted_steps = sorted(step_timings.items(), key=lambda item: item[1], reverse=True)
587
+
588
+ # Calculate "CPU Time" (Sum of all work) vs "Wall Time" (Real world time)
589
+ total_cpu_time = sum(step_timings.values())
590
+
591
+ for step_name, duration in sorted_steps:
592
+ # Percent of the Wall Clock time this step was active
593
+ percent = (duration / total_time) * 100 if total_time > 0 else 0
594
+
595
+ minutes = int(duration // 60)
596
+ seconds = duration % 60
597
+ if minutes > 0:
598
+ time_str = f"{minutes}:{seconds:05.2f}"
599
+ else:
600
+ time_str = f"{seconds:.2f}s"
601
+
602
+ logger.log(f"{step_name:<30} {percent:>5.1f}% {time_str}")
603
+
604
+ t_min = int(total_time // 60)
605
+ t_sec = total_time % 60
606
+
607
+ # Calculate parallelism factor (e.g., 2.5x speedup)
608
+ speedup = total_cpu_time / total_time if total_time > 0 else 1.0
609
+
610
+ logger.log("-" * 50)
611
+ logger.log(f"Wall Time: {t_min}:{t_sec:05.2f} Parallel Speedup: {speedup:.1f}x")
385
612
 
386
613
  class BuildReporter:
387
- """Generates human-readable descriptions and diagrams of the workflow."""
614
+ """Generates human-readable description of the workflow."""
388
615
 
389
616
  def __init__(self, config: Dict):
390
617
  self.config = config
391
618
 
392
619
  def generate_mermaid_diagram(self, graph: nx.DiGraph) -> str:
393
- """Creates a Mermaid graph syntax string for the workflow."""
620
+ """Creates a Mermaid graph for the workflow."""
394
621
  if not graph.nodes:
395
622
  return "graph TD;\n Empty_Workflow[Workflow is empty];"
396
623
 
@@ -417,8 +644,9 @@ class BuildReporter:
417
644
  def describe_workflow(self, profile_name: str) -> str:
418
645
  """Generates a full Markdown report for the workflow."""
419
646
  import datetime
420
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
421
- project_name = self.config.get("GENERAL", {}).get("PROJECT_NAME", "LiteBuild Project")
647
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
648
+ project_cfg = self.config.get(YAMLSection.PROJECT)
649
+ project_name = project_cfg.get("PROJECT_NAME", "LiteBuild Project")
422
650
 
423
651
  # 1. Generate the Plan to resolve all variables
424
652
  planner = BuildPlanner(self.config, {})
@@ -439,33 +667,51 @@ class BuildReporter:
439
667
  f"# {project_name} Pipeline Documentation",
440
668
  "",
441
669
  f"**Profile:** `{profile_name}` ",
442
- f"**Generated:** {timestamp} ",
670
+ f"**Date:** {timestamp} ",
443
671
  f"**Target Output:** `{final_output}`",
444
672
  "",
445
673
  "---",
446
674
  ""
447
675
  ]
448
676
 
677
+ # --- OVERVIEW SECTION ---
678
+ # Checks for the OVERVIEW key in the config
679
+
680
+ overview_text = project_cfg.get("OVERVIEW")
681
+ if overview_text:
682
+ lines.append("## Overview")
683
+ lines.append(overview_text.strip())
684
+ lines.append("")
685
+ lines.append("---")
686
+ lines.append("")
687
+
449
688
  # --- MERMAID DIAGRAM ---
450
- lines.append("## Workflow Visualization")
689
+ lines.append("## Workflow ")
451
690
  lines.append("```mermaid")
452
691
  lines.append(self.generate_mermaid_diagram(graph))
453
692
  lines.append("```")
454
693
  lines.append("")
455
694
 
456
695
  # --- STEP DETAIL ---
457
- lines.append("## Step-by-Step Guide")
696
+ lines.append("## Detailed Steps")
458
697
 
459
698
  workflow_def = self.config.get("WORKFLOW", {})
460
699
 
461
700
  for node_name in build_order:
462
701
  cmd_data = plan.command_map[node_name]
463
702
  step_def = workflow_def.get(node_name, {})
464
-
465
- description = step_def.get("DESCRIPTION", f"Executes rule: `{step_def.get('RULE', {}).get('NAME')}`")
703
+ rule_name = step_def.get('RULE', {}).get('NAME')
466
704
 
467
705
  lines.append(f"### {node_name}")
468
- lines.append(f"_{description}_")
706
+
707
+ # --- DESCRIPTION LOGIC ---
708
+ if "DESCRIPTION" in step_def:
709
+ # Use blockquote for user-defined descriptions (handles multi-line well)
710
+ lines.append(f"> {step_def['DESCRIPTION']}")
711
+ else:
712
+ # Fallback to technical description
713
+ lines.append(f"_Executes rule: `{rule_name}`_")
714
+
469
715
  lines.append("")
470
716
 
471
717
  # Inputs
@@ -488,7 +734,6 @@ class BuildReporter:
488
734
 
489
735
  return "\n".join(lines)
490
736
 
491
-
492
737
  class BuildStateManager:
493
738
  """Manages loading and saving the .build_state.json file."""
494
739
 
@@ -517,4 +762,4 @@ class BuildStateManager:
517
762
  # --- Worker initializer accepts a logger object ---
518
763
  def setup_worker_logger(logger: BuildLogger):
519
764
  """Initializes the logger for a worker process."""
520
- setup_logger(logger)
765
+ setup_logger(logger)
@@ -1,7 +1,7 @@
1
1
  # build_logger.py
2
2
 
3
3
  from contextlib import nullcontext
4
- from enum import IntEnum # <-- ADDED
4
+ from enum import IntEnum
5
5
  from pathlib import Path
6
6
  import sys
7
7
  from typing import Optional, Tuple, Callable, Any, Union, TextIO
@@ -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()