LiteBuild 0.3.2__tar.gz → 0.5__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.
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_engine.py +223 -54
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_logger.py +1 -1
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/build_workers.py +95 -27
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/command_generator.py +113 -73
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/dependency_graph.py +8 -1
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/lite_build_controller.py +15 -2
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/lite_build_runner.py +159 -48
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/schema.py +29 -16
- litebuild-0.5/LiteBuild/sleep_inhibitor.py +57 -0
- litebuild-0.5/LiteBuild.egg-info/PKG-INFO +139 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/SOURCES.txt +1 -0
- litebuild-0.5/LiteBuild.egg-info/requires.txt +5 -0
- litebuild-0.5/PKG-INFO +139 -0
- litebuild-0.5/docs/readme.md +112 -0
- {litebuild-0.3.2 → litebuild-0.5}/pyproject.toml +5 -14
- litebuild-0.3.2/LiteBuild.egg-info/PKG-INFO +0 -116
- litebuild-0.3.2/LiteBuild.egg-info/requires.txt +0 -9
- litebuild-0.3.2/PKG-INFO +0 -116
- litebuild-0.3.2/docs/readme.md +0 -81
- {litebuild-0.3.2 → litebuild-0.5}/LICENSE +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/config_loader.py +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild/litebuild.py +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/dependency_links.txt +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/entry_points.txt +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/LiteBuild.egg-info/top_level.txt +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/setup.cfg +0 -0
- {litebuild-0.3.2 → litebuild-0.5}/tests/test_build_engine.py +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
# build_engine.py
|
|
2
1
|
from concurrent.futures import ProcessPoolExecutor
|
|
2
|
+
import datetime
|
|
3
|
+
import difflib
|
|
3
4
|
from enum import IntEnum
|
|
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
|
|
@@ -18,7 +20,7 @@ from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator
|
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
class UpdateCode(IntEnum):
|
|
21
|
-
"""Enumeration for why a build step is
|
|
23
|
+
"""Enumeration for why a build step is outdated."""
|
|
22
24
|
UP_TO_DATE = 0
|
|
23
25
|
MISSING_OUTPUT = 1
|
|
24
26
|
NOT_TRACKED = 2
|
|
@@ -28,6 +30,7 @@ class UpdateCode(IntEnum):
|
|
|
28
30
|
NEWER_INPUT = 6
|
|
29
31
|
MISSING_INPUT = 7
|
|
30
32
|
STALE_TARGET = 8
|
|
33
|
+
FORCED = 9
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
class BuildStep(NamedTuple):
|
|
@@ -61,6 +64,20 @@ class BuildEngine:
|
|
|
61
64
|
|
|
62
65
|
self.config = config_data
|
|
63
66
|
self.state_file = state_file
|
|
67
|
+
# Validate Input Directory
|
|
68
|
+
input_dir = config_data.get("GENERAL", {}).get("INPUT_DIRECTORY")
|
|
69
|
+
if input_dir:
|
|
70
|
+
path = Path(input_dir)
|
|
71
|
+
if not path.exists():
|
|
72
|
+
raise FileNotFoundError(
|
|
73
|
+
f"❌ Configuration Error: INPUT_DIRECTORY does not exist.\n"
|
|
74
|
+
f" Path: {path.absolute()}"
|
|
75
|
+
)
|
|
76
|
+
if not path.is_dir():
|
|
77
|
+
raise NotADirectoryError(
|
|
78
|
+
f"❌ Configuration Error: INPUT_DIRECTORY is not a directory.\n"
|
|
79
|
+
f" Path: {path.absolute()}"
|
|
80
|
+
)
|
|
64
81
|
|
|
65
82
|
@classmethod
|
|
66
83
|
def from_file(
|
|
@@ -78,43 +95,73 @@ class BuildEngine:
|
|
|
78
95
|
# Re-raise to be handled by the calling script (CLI or GUI)
|
|
79
96
|
raise e
|
|
80
97
|
|
|
81
|
-
def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None):
|
|
98
|
+
def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None, status_callback=None, force_rebuild=False):
|
|
82
99
|
"""
|
|
83
100
|
Plans and executes the build for a specific workflow entry step.
|
|
84
|
-
|
|
85
|
-
Args:
|
|
86
|
-
final_step_name: The required step of the workflow DAG to execute.
|
|
87
|
-
profile_name: An optional parameter context .
|
|
88
|
-
logger: The logger instance to use for output.
|
|
89
101
|
"""
|
|
90
102
|
if logger is None:
|
|
91
103
|
logger = get_logger()
|
|
92
104
|
setup_logger(logger)
|
|
93
105
|
|
|
94
|
-
|
|
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} "
|
|
115
|
+
if profile_name:
|
|
116
|
+
context_str += f" Profile: {profile_name}"
|
|
117
|
+
|
|
118
|
+
# Get the current date and time
|
|
119
|
+
#now = datetime.now()
|
|
120
|
+
|
|
121
|
+
# Format the time as a string (HH:MM:SS) using strftime()
|
|
122
|
+
current_time = "" #now.strftime("%H:%M:%S")
|
|
123
|
+
|
|
124
|
+
logger.log(f"🔵 Executing build for step {final_step_name} - {current_time}")
|
|
125
|
+
logger.log(f"ℹ️ {context_str}\n")
|
|
95
126
|
|
|
96
127
|
try:
|
|
97
128
|
state_manager = BuildStateManager(self.state_file)
|
|
98
129
|
planner = BuildPlanner(self.config, state_manager.load_state())
|
|
99
|
-
plan = planner.plan_build(profile_name, final_step_name)
|
|
130
|
+
plan = planner.plan_build(profile_name, final_step_name, force_rebuild=force_rebuild)
|
|
100
131
|
|
|
101
132
|
executor = BuildExecutor(state_manager, self.config)
|
|
102
|
-
success = executor.execute_plan(plan, logger)
|
|
133
|
+
success = executor.execute_plan(plan, logger, status_callback=status_callback)
|
|
103
134
|
except Exception as e:
|
|
104
135
|
success = False
|
|
136
|
+
# --- ERROR REPORTING ---
|
|
137
|
+
#logger.log(f"\n❌ ERROR")
|
|
105
138
|
logger.log(f"{e}")
|
|
139
|
+
#logger.log("\n--- Traceback ---")
|
|
140
|
+
#logger.log(traceback.format_exc())
|
|
141
|
+
|
|
142
|
+
# Update status callback if present
|
|
143
|
+
if status_callback:
|
|
144
|
+
status_callback("step", 0, 0, "error")
|
|
145
|
+
# Get the current date and time
|
|
146
|
+
#now = datetime.now()
|
|
147
|
+
|
|
148
|
+
# Format the time as a string (HH:MM:SS) using strftime()
|
|
149
|
+
current_time = "" #now.strftime("%H:%M:%S")
|
|
106
150
|
|
|
107
151
|
if success:
|
|
108
|
-
logger.log(f"\n✅ Build finished successfully.")
|
|
152
|
+
logger.log(f"\n✅ Build finished successfully. {current_time}")
|
|
109
153
|
else:
|
|
110
|
-
logger.log(f"
|
|
154
|
+
logger.log(f"🔴Build failed for {final_step_name}. {current_time}")
|
|
155
|
+
|
|
156
|
+
def has_profile(self, profile_name: str) -> bool:
|
|
157
|
+
"""Checks if a specific profile key exists in the YAML config."""
|
|
158
|
+
return profile_name in self.config.get('PROFILES', {})
|
|
111
159
|
|
|
112
160
|
def describe(self, profile_name: str) -> str:
|
|
113
161
|
"""Generates a Markdown description of the workflow for a given profile."""
|
|
114
162
|
reporter = BuildReporter(self.config)
|
|
115
163
|
return reporter.describe_workflow(profile_name)
|
|
116
164
|
|
|
117
|
-
|
|
118
165
|
class BuildPlanner:
|
|
119
166
|
"""
|
|
120
167
|
Analyzes the workflow and build state to create an incremental build plan.
|
|
@@ -125,10 +172,15 @@ class BuildPlanner:
|
|
|
125
172
|
self.build_state = build_state
|
|
126
173
|
self.logger = get_logger()
|
|
127
174
|
|
|
128
|
-
def _is_step_outdated(self, command: Dict) -> Tuple[UpdateCode, str]:
|
|
175
|
+
def _is_step_outdated(self, command: Dict, force_rebuild: bool = False) -> Tuple[UpdateCode, str]:
|
|
176
|
+
# 1. Force Check (Short Circuit)
|
|
177
|
+
if force_rebuild:
|
|
178
|
+
self.logger.debug(f" - RESULT: Rebuild forced by user. (COMMAND_CHANGED)")
|
|
179
|
+
return UpdateCode.FORCED, ""
|
|
180
|
+
|
|
129
181
|
"""Checks a single step to see if it needs to be rebuilt, with detailed debug logging."""
|
|
130
182
|
output_path = command['output']
|
|
131
|
-
node_name = command.get('node_name', 'UnknownStep')
|
|
183
|
+
node_name = command.get('node_name', 'UnknownStep')
|
|
132
184
|
|
|
133
185
|
self.logger.debug(f"\n--- Checking status of step '{node_name}' ---")
|
|
134
186
|
self.logger.debug(f" - Output file: '{output_path}'")
|
|
@@ -195,7 +247,7 @@ class BuildPlanner:
|
|
|
195
247
|
self.logger.debug(f" - RESULT: Step is up-to-date. (UP_TO_DATE)")
|
|
196
248
|
return UpdateCode.UP_TO_DATE, ""
|
|
197
249
|
|
|
198
|
-
def plan_build(self, profile_name: str, final_step_name: str = None) -> BuildPlan:
|
|
250
|
+
def plan_build(self, profile_name: str, final_step_name: str = None, force_rebuild: bool = False) -> BuildPlan:
|
|
199
251
|
command_map, execution_graph = self._generate_command_map_and_graph(
|
|
200
252
|
profile_name, final_step_name
|
|
201
253
|
)
|
|
@@ -207,7 +259,7 @@ class BuildPlanner:
|
|
|
207
259
|
build_order = list(nx.topological_sort(execution_graph))
|
|
208
260
|
for node_name in build_order:
|
|
209
261
|
command = command_map[node_name]
|
|
210
|
-
update_code, context = self._is_step_outdated(command)
|
|
262
|
+
update_code, context = self._is_step_outdated(command, force_rebuild=force_rebuild)
|
|
211
263
|
if update_code != UpdateCode.UP_TO_DATE:
|
|
212
264
|
initially_outdated[node_name] = (update_code, context)
|
|
213
265
|
|
|
@@ -227,6 +279,14 @@ class BuildPlanner:
|
|
|
227
279
|
steps_to_skip.append(BuildStep(node_name, step_command, UpdateCode.UP_TO_DATE, ""))
|
|
228
280
|
return BuildPlan(steps_to_run, steps_to_skip, command_map, execution_graph)
|
|
229
281
|
|
|
282
|
+
@staticmethod
|
|
283
|
+
def get_suggestion(invalid_key: str, valid_options: list[str]) -> str:
|
|
284
|
+
"""Returns a 'Did you mean X?' string if a close match is found."""
|
|
285
|
+
matches = difflib.get_close_matches(invalid_key, valid_options, n=1, cutoff=0.6)
|
|
286
|
+
if matches:
|
|
287
|
+
return f"\n Did you mean '{matches[0]}'?"
|
|
288
|
+
return ""
|
|
289
|
+
|
|
230
290
|
def _generate_command_map_and_graph(self, profile_name: str, final_step_name: str = None) -> \
|
|
231
291
|
Tuple[Dict, nx.DiGraph]:
|
|
232
292
|
"""Generates all commands and the dependency graph for a given profile."""
|
|
@@ -237,8 +297,10 @@ class BuildPlanner:
|
|
|
237
297
|
profile_config = all_profiles[profile_name]
|
|
238
298
|
else:
|
|
239
299
|
available = "\n - ".join(all_profiles.keys())
|
|
300
|
+
hint = self.get_suggestion(profile_name, list(all_profiles.keys()))
|
|
240
301
|
raise ValueError(
|
|
241
|
-
f"
|
|
302
|
+
f"\nProfile '{profile_name}' not found.{hint}\n"
|
|
303
|
+
f"\nAvailable profiles:\n - {available}\n"
|
|
242
304
|
)
|
|
243
305
|
|
|
244
306
|
graph_manager = DependencyGraph(self.config.get("WORKFLOW", {}))
|
|
@@ -252,18 +314,23 @@ class BuildPlanner:
|
|
|
252
314
|
input_basenames = context.get("INPUT_FILES")
|
|
253
315
|
|
|
254
316
|
if input_dir and input_basenames:
|
|
255
|
-
# Create the list of full paths
|
|
256
317
|
full_paths = [os.path.join(input_dir, f) for f in input_basenames]
|
|
257
|
-
# Overwrite the INPUT_FILES in the context with the full paths.
|
|
258
|
-
# This makes the fully resolved list available to all template substitutions.
|
|
259
318
|
context['INPUT_FILES'] = full_paths
|
|
260
319
|
|
|
261
320
|
command_map, resolved_outputs = {}, {}
|
|
262
321
|
for node_name in nx.topological_sort(execution_graph):
|
|
263
322
|
node_data = execution_graph.nodes[node_name]
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
323
|
+
|
|
324
|
+
# --- IMPROVED ERROR HANDLING ---
|
|
325
|
+
try:
|
|
326
|
+
command_map[node_name] = command_gen.generate_for_node(
|
|
327
|
+
node_name, node_data, context, resolved_outputs
|
|
328
|
+
)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
# This catches errors in CommandGenerator (like invalid format strings)
|
|
331
|
+
# and adds the context of WHICH rule failed.
|
|
332
|
+
raise RuntimeError(f"Error generating COMMAND for rule '{node_name}': {e}") from e
|
|
333
|
+
|
|
267
334
|
return command_map, execution_graph
|
|
268
335
|
|
|
269
336
|
class BuildExecutor:
|
|
@@ -274,33 +341,43 @@ class BuildExecutor:
|
|
|
274
341
|
self.build_state = state_manager.load_state()
|
|
275
342
|
self.config = config
|
|
276
343
|
self.update_codes = {
|
|
277
|
-
UpdateCode.UP_TO_DATE: "
|
|
278
|
-
UpdateCode.NOT_TRACKED: "(
|
|
279
|
-
UpdateCode.COMMAND_CHANGED: "(
|
|
280
|
-
UpdateCode.INPUTS_CHANGED: "(
|
|
281
|
-
UpdateCode.PARAMS_CHANGED: "(
|
|
282
|
-
UpdateCode.NEWER_INPUT: "(
|
|
283
|
-
UpdateCode.MISSING_INPUT: "(
|
|
284
|
-
UpdateCode.STALE_TARGET: "(
|
|
344
|
+
UpdateCode.UP_TO_DATE: "", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
|
|
345
|
+
UpdateCode.NOT_TRACKED: "(first build)",
|
|
346
|
+
UpdateCode.COMMAND_CHANGED: "(command has changed)",
|
|
347
|
+
UpdateCode.INPUTS_CHANGED: "(input file list has changed)",
|
|
348
|
+
UpdateCode.PARAMS_CHANGED: "(parameters have changed)",
|
|
349
|
+
UpdateCode.NEWER_INPUT: "(input '{context}' is newer)",
|
|
350
|
+
UpdateCode.MISSING_INPUT: "(input '{context}' is missing)",
|
|
351
|
+
UpdateCode.STALE_TARGET: "(stale target)",
|
|
352
|
+
UpdateCode.FORCED: "(forced)"
|
|
285
353
|
}
|
|
286
354
|
|
|
287
|
-
def execute_plan(self, plan: BuildPlan, logger: BuildLogger) -> bool:
|
|
355
|
+
def execute_plan(self, plan: BuildPlan, logger: BuildLogger, status_callback=None) -> bool:
|
|
288
356
|
"""Executes the build plan, managing parallel execution and state."""
|
|
289
357
|
total_to_run = len(plan.steps_to_run)
|
|
290
358
|
finished_count = 0
|
|
291
359
|
|
|
360
|
+
# Track timing for reporting
|
|
361
|
+
step_timings = {}
|
|
362
|
+
build_start_time = time.time()
|
|
363
|
+
|
|
364
|
+
# --- Initial Status Update ---
|
|
365
|
+
if status_callback:
|
|
366
|
+
status_callback("step", 0, total_to_run, "started")
|
|
367
|
+
|
|
292
368
|
for step in plan.steps_to_skip:
|
|
293
369
|
logger.log(f"Skipping '{step.node_name}' (up-to-date)")
|
|
294
370
|
|
|
295
371
|
if not plan.steps_to_run:
|
|
372
|
+
if status_callback:
|
|
373
|
+
status_callback("step", 0, 0, "done")
|
|
296
374
|
return True
|
|
297
375
|
|
|
298
|
-
# Ask the logger for the information needed to initialize workers.
|
|
299
|
-
# This is polymorphic: a FileLogger provides info, a StreamLogger does not.
|
|
300
376
|
worker_init_info = logger.get_worker_init_info()
|
|
301
377
|
initializer, initargs = (worker_init_info if worker_init_info else (None, ()))
|
|
302
378
|
|
|
303
379
|
tasks_to_run_map = {s.node_name: s for s in plan.steps_to_run}
|
|
380
|
+
|
|
304
381
|
for generation in nx.topological_generations(plan.execution_graph):
|
|
305
382
|
tasks_this_generation = []
|
|
306
383
|
for node_name in generation:
|
|
@@ -323,62 +400,138 @@ class BuildExecutor:
|
|
|
323
400
|
halt_build = False
|
|
324
401
|
for status, result_data in results:
|
|
325
402
|
step_name = result_data.get('step_name', 'N/A')
|
|
403
|
+
|
|
404
|
+
# Capture timing if available
|
|
405
|
+
if 'elapsed_time' in result_data:
|
|
406
|
+
step_timings[step_name] = result_data['elapsed_time']
|
|
407
|
+
|
|
326
408
|
if status == 'EXECUTED':
|
|
327
409
|
finished_count += 1
|
|
328
410
|
logger.log(f"✅ Finished step '{step_name}' [{finished_count}/{total_to_run}]")
|
|
411
|
+
if status_callback:
|
|
412
|
+
status_callback("step", finished_count, total_to_run, "done")
|
|
329
413
|
self.build_state[result_data['output_path']] = {
|
|
330
414
|
"hashes": result_data['hashes'], "mtime": result_data['mtime']
|
|
331
415
|
}
|
|
332
416
|
elif status == 'FAILED':
|
|
333
417
|
halt_build = True
|
|
334
|
-
logger.log(f"
|
|
418
|
+
logger.log(f"🔺 Build failed for Step '{step_name}'")
|
|
419
|
+
if status_callback:
|
|
420
|
+
status_callback("step", finished_count, total_to_run, "error")
|
|
421
|
+
|
|
335
422
|
if halt_build:
|
|
336
423
|
self.state_manager.save_state(self.build_state)
|
|
337
424
|
return False
|
|
425
|
+
|
|
338
426
|
self.state_manager.save_state(self.build_state)
|
|
427
|
+
|
|
428
|
+
# --- TIMING REPORT ---
|
|
429
|
+
total_build_time = time.time() - build_start_time
|
|
430
|
+
self._print_timing_report(logger, step_timings, total_build_time)
|
|
431
|
+
|
|
339
432
|
return True
|
|
340
433
|
|
|
341
434
|
@staticmethod
|
|
342
435
|
def _run_single_command(task: Tuple[str, Dict, str]) -> Tuple[str, Dict]:
|
|
343
|
-
"""Runs a command and streams all output to the configured log
|
|
436
|
+
"""Runs a command and streams all output to the configured log."""
|
|
344
437
|
logger = get_logger()
|
|
345
438
|
step_name, command, update_text = task
|
|
346
439
|
output_path = command['output']
|
|
347
440
|
|
|
441
|
+
# --- Helper for log truncation ---
|
|
442
|
+
def _truncate(text: str, limit: int = 400) -> str:
|
|
443
|
+
"""Keeps the start and end of long strings."""
|
|
444
|
+
if len(text) <= limit:
|
|
445
|
+
return text
|
|
446
|
+
|
|
447
|
+
# Keep first 40% and last 40% of the limit
|
|
448
|
+
keep = int(limit * 0.4)
|
|
449
|
+
omitted_count = len(text) - (keep * 2)
|
|
450
|
+
|
|
451
|
+
# Formatting: Clear brackets with internal spacing
|
|
452
|
+
return f"{text[:keep]} [ ... {omitted_count} chars truncated ... ] {text[-keep:]}"
|
|
453
|
+
|
|
454
|
+
# Log the command (Truncated)
|
|
455
|
+
cmd_display = _truncate(command['cmd_string'])
|
|
348
456
|
logger.log(f"\n▶️ Running step '{step_name}': {update_text}")
|
|
349
|
-
logger.log(f" [{step_name}] {
|
|
457
|
+
logger.log(f" [{step_name}] {cmd_display}")
|
|
458
|
+
|
|
459
|
+
start_time = time.perf_counter()
|
|
350
460
|
|
|
351
461
|
try:
|
|
352
462
|
process = subprocess.Popen(
|
|
353
463
|
command['cmd_string'], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
354
464
|
text=True, encoding='utf-8', errors='replace'
|
|
355
465
|
)
|
|
466
|
+
|
|
356
467
|
for line in iter(process.stdout.readline, ''):
|
|
357
|
-
|
|
468
|
+
clean_line = line.strip()
|
|
469
|
+
if clean_line:
|
|
470
|
+
# Also truncate massive output lines (rare but possible with WKT echos)
|
|
471
|
+
logger.log(f" [{step_name}] {_truncate(clean_line)}")
|
|
472
|
+
|
|
358
473
|
return_code = process.wait()
|
|
474
|
+
|
|
475
|
+
end_time = time.perf_counter()
|
|
476
|
+
elapsed = end_time - start_time
|
|
477
|
+
|
|
359
478
|
if return_code != 0:
|
|
360
479
|
raise subprocess.CalledProcessError(return_code, "")
|
|
361
480
|
|
|
362
481
|
new_mtime = os.path.getmtime(output_path)
|
|
363
482
|
result_data = {
|
|
364
|
-
'step_name': step_name,
|
|
365
|
-
'
|
|
483
|
+
'step_name': step_name,
|
|
484
|
+
'output_path': output_path,
|
|
485
|
+
'hashes': command['hashes'],
|
|
486
|
+
'mtime': new_mtime,
|
|
487
|
+
'elapsed_time': elapsed
|
|
366
488
|
}
|
|
367
489
|
return 'EXECUTED', result_data
|
|
368
490
|
except Exception as e:
|
|
369
|
-
logger.log(f"
|
|
491
|
+
logger.log(f"🔺 Step '{step_name}' failed: {e}")
|
|
370
492
|
result_data = {'step_name': step_name}
|
|
371
493
|
return 'FAILED', result_data
|
|
372
494
|
|
|
495
|
+
def _print_timing_report(self, logger: BuildLogger, step_timings: Dict[str, float], total_time: float):
|
|
496
|
+
"""Generates and logs the timing summary table."""
|
|
497
|
+
logger.log("\n🔵 Timing Report:")
|
|
498
|
+
|
|
499
|
+
# Sort by duration (Longest first)
|
|
500
|
+
sorted_steps = sorted(step_timings.items(), key=lambda item: item[1], reverse=True)
|
|
501
|
+
|
|
502
|
+
# Calculate "CPU Time" (Sum of all work) vs "Wall Time" (Real world time)
|
|
503
|
+
total_cpu_time = sum(step_timings.values())
|
|
504
|
+
|
|
505
|
+
for step_name, duration in sorted_steps:
|
|
506
|
+
# Percent of the Wall Clock time this step was active
|
|
507
|
+
percent = (duration / total_time) * 100 if total_time > 0 else 0
|
|
508
|
+
|
|
509
|
+
minutes = int(duration // 60)
|
|
510
|
+
seconds = duration % 60
|
|
511
|
+
if minutes > 0:
|
|
512
|
+
time_str = f"{minutes}:{seconds:05.2f}"
|
|
513
|
+
else:
|
|
514
|
+
time_str = f"{seconds:.2f}s"
|
|
515
|
+
|
|
516
|
+
logger.log(f"{step_name:<30} {percent:>5.1f}% {time_str}")
|
|
517
|
+
|
|
518
|
+
t_min = int(total_time // 60)
|
|
519
|
+
t_sec = total_time % 60
|
|
520
|
+
|
|
521
|
+
# Calculate parallelism factor (e.g., 2.5x speedup)
|
|
522
|
+
speedup = total_cpu_time / total_time if total_time > 0 else 1.0
|
|
523
|
+
|
|
524
|
+
logger.log("-" * 50)
|
|
525
|
+
logger.log(f"Wall Time: {t_min}:{t_sec:05.2f} Parallel Speedup: {speedup:.1f}x")
|
|
373
526
|
|
|
374
527
|
class BuildReporter:
|
|
375
|
-
"""Generates human-readable
|
|
528
|
+
"""Generates human-readable description of the workflow."""
|
|
376
529
|
|
|
377
530
|
def __init__(self, config: Dict):
|
|
378
531
|
self.config = config
|
|
379
532
|
|
|
380
533
|
def generate_mermaid_diagram(self, graph: nx.DiGraph) -> str:
|
|
381
|
-
"""Creates a Mermaid graph
|
|
534
|
+
"""Creates a Mermaid graph for the workflow."""
|
|
382
535
|
if not graph.nodes:
|
|
383
536
|
return "graph TD;\n Empty_Workflow[Workflow is empty];"
|
|
384
537
|
|
|
@@ -405,7 +558,7 @@ class BuildReporter:
|
|
|
405
558
|
def describe_workflow(self, profile_name: str) -> str:
|
|
406
559
|
"""Generates a full Markdown report for the workflow."""
|
|
407
560
|
import datetime
|
|
408
|
-
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M
|
|
561
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
409
562
|
project_name = self.config.get("GENERAL", {}).get("PROJECT_NAME", "LiteBuild Project")
|
|
410
563
|
|
|
411
564
|
# 1. Generate the Plan to resolve all variables
|
|
@@ -427,33 +580,50 @@ class BuildReporter:
|
|
|
427
580
|
f"# {project_name} Pipeline Documentation",
|
|
428
581
|
"",
|
|
429
582
|
f"**Profile:** `{profile_name}` ",
|
|
430
|
-
f"**
|
|
583
|
+
f"**Date:** {timestamp} ",
|
|
431
584
|
f"**Target Output:** `{final_output}`",
|
|
432
585
|
"",
|
|
433
586
|
"---",
|
|
434
587
|
""
|
|
435
588
|
]
|
|
436
589
|
|
|
590
|
+
# --- OVERVIEW SECTION ---
|
|
591
|
+
# Checks for the global OVERVIEW key in the config
|
|
592
|
+
overview_text = self.config.get("OVERVIEW")
|
|
593
|
+
if overview_text:
|
|
594
|
+
lines.append("## Overview")
|
|
595
|
+
lines.append(overview_text.strip())
|
|
596
|
+
lines.append("")
|
|
597
|
+
lines.append("---")
|
|
598
|
+
lines.append("")
|
|
599
|
+
|
|
437
600
|
# --- MERMAID DIAGRAM ---
|
|
438
|
-
lines.append("## Workflow
|
|
601
|
+
lines.append("## Workflow ")
|
|
439
602
|
lines.append("```mermaid")
|
|
440
603
|
lines.append(self.generate_mermaid_diagram(graph))
|
|
441
604
|
lines.append("```")
|
|
442
605
|
lines.append("")
|
|
443
606
|
|
|
444
607
|
# --- STEP DETAIL ---
|
|
445
|
-
lines.append("##
|
|
608
|
+
lines.append("## Detailed Steps")
|
|
446
609
|
|
|
447
610
|
workflow_def = self.config.get("WORKFLOW", {})
|
|
448
611
|
|
|
449
612
|
for node_name in build_order:
|
|
450
613
|
cmd_data = plan.command_map[node_name]
|
|
451
614
|
step_def = workflow_def.get(node_name, {})
|
|
452
|
-
|
|
453
|
-
description = step_def.get("DESCRIPTION", f"Executes rule: `{step_def.get('RULE', {}).get('NAME')}`")
|
|
615
|
+
rule_name = step_def.get('RULE', {}).get('NAME')
|
|
454
616
|
|
|
455
617
|
lines.append(f"### {node_name}")
|
|
456
|
-
|
|
618
|
+
|
|
619
|
+
# --- DESCRIPTION LOGIC ---
|
|
620
|
+
if "DESCRIPTION" in step_def:
|
|
621
|
+
# Use blockquote for user-defined descriptions (handles multi-line well)
|
|
622
|
+
lines.append(f"> {step_def['DESCRIPTION']}")
|
|
623
|
+
else:
|
|
624
|
+
# Fallback to technical description
|
|
625
|
+
lines.append(f"_Executes rule: `{rule_name}`_")
|
|
626
|
+
|
|
457
627
|
lines.append("")
|
|
458
628
|
|
|
459
629
|
# Inputs
|
|
@@ -476,7 +646,6 @@ class BuildReporter:
|
|
|
476
646
|
|
|
477
647
|
return "\n".join(lines)
|
|
478
648
|
|
|
479
|
-
|
|
480
649
|
class BuildStateManager:
|
|
481
650
|
"""Manages loading and saving the .build_state.json file."""
|
|
482
651
|
|
|
@@ -505,4 +674,4 @@ class BuildStateManager:
|
|
|
505
674
|
# --- Worker initializer accepts a logger object ---
|
|
506
675
|
def setup_worker_logger(logger: BuildLogger):
|
|
507
676
|
"""Initializes the logger for a worker process."""
|
|
508
|
-
setup_logger(logger)
|
|
677
|
+
setup_logger(logger)
|