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.
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/build_engine.py +322 -77
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/build_logger.py +1 -2
- litebuild-0.7/LiteBuild/build_util.py +43 -0
- litebuild-0.7/LiteBuild/build_workers.py +299 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/command_generator.py +142 -92
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/dependency_graph.py +8 -1
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/lite_build_controller.py +11 -5
- litebuild-0.7/LiteBuild/lite_build_runner.py +305 -0
- litebuild-0.7/LiteBuild/litebuild.py +274 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/schema.py +55 -24
- litebuild-0.7/LiteBuild/sleep_inhibitor.py +57 -0
- litebuild-0.7/LiteBuild.egg-info/PKG-INFO +273 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/SOURCES.txt +2 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/entry_points.txt +1 -0
- litebuild-0.7/PKG-INFO +273 -0
- litebuild-0.7/docs/readme.md +246 -0
- {litebuild-0.4.1 → litebuild-0.7}/pyproject.toml +2 -1
- litebuild-0.4.1/LiteBuild/build_workers.py +0 -176
- litebuild-0.4.1/LiteBuild/lite_build_runner.py +0 -187
- litebuild-0.4.1/LiteBuild/litebuild.py +0 -129
- litebuild-0.4.1/LiteBuild.egg-info/PKG-INFO +0 -108
- litebuild-0.4.1/PKG-INFO +0 -108
- litebuild-0.4.1/docs/readme.md +0 -81
- {litebuild-0.4.1 → litebuild-0.7}/LICENSE +0 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild/config_loader.py +0 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/dependency_links.txt +0 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/requires.txt +0 -0
- {litebuild-0.4.1 → litebuild-0.7}/LiteBuild.egg-info/top_level.txt +0 -0
- {litebuild-0.4.1 → litebuild-0.7}/setup.cfg +0 -0
- {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
|
-
|
|
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
|
|
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
|
|
59
|
-
config_data[
|
|
60
|
-
config_data[
|
|
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
|
|
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
|
-
|
|
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"
|
|
112
|
+
context_str += f" Profile: {profile_name}"
|
|
100
113
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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(
|
|
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"
|
|
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(
|
|
259
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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: "
|
|
290
|
-
UpdateCode.NOT_TRACKED: "(
|
|
291
|
-
UpdateCode.COMMAND_CHANGED: "(
|
|
292
|
-
UpdateCode.INPUTS_CHANGED: "(
|
|
293
|
-
UpdateCode.PARAMS_CHANGED: "(
|
|
294
|
-
UpdateCode.NEWER_INPUT: "(
|
|
295
|
-
UpdateCode.MISSING_INPUT: "(
|
|
296
|
-
UpdateCode.STALE_TARGET: "(
|
|
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
|
-
|
|
305
|
-
|
|
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(
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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,
|
|
377
|
-
'
|
|
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
|
|
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
|
|
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
|
|
421
|
-
|
|
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"**
|
|
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
|
|
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("##
|
|
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
|
-
|
|
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
|
|
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()
|