LiteBuild 0.2__tar.gz → 0.3.2__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/build_engine.py +508 -0
- litebuild-0.3.2/LiteBuild/build_logger.py +118 -0
- litebuild-0.3.2/LiteBuild/build_workers.py +176 -0
- litebuild-0.3.2/LiteBuild/command_generator.py +298 -0
- litebuild-0.3.2/LiteBuild/config_loader.py +44 -0
- litebuild-0.3.2/LiteBuild/dependency_graph.py +49 -0
- litebuild-0.3.2/LiteBuild/lite_build_controller.py +126 -0
- litebuild-0.3.2/LiteBuild/lite_build_runner.py +187 -0
- litebuild-0.3.2/LiteBuild/litebuild.py +129 -0
- litebuild-0.3.2/LiteBuild/schema.py +126 -0
- litebuild-0.3.2/LiteBuild.egg-info/PKG-INFO +116 -0
- litebuild-0.3.2/LiteBuild.egg-info/SOURCES.txt +20 -0
- {litebuild-0.2 → litebuild-0.3.2}/LiteBuild.egg-info/top_level.txt +1 -0
- litebuild-0.3.2/PKG-INFO +116 -0
- litebuild-0.3.2/docs/readme.md +81 -0
- {litebuild-0.2 → litebuild-0.3.2}/pyproject.toml +2 -2
- litebuild-0.2/LiteBuild.egg-info/PKG-INFO +0 -34
- litebuild-0.2/LiteBuild.egg-info/SOURCES.txt +0 -9
- litebuild-0.2/PKG-INFO +0 -34
- {litebuild-0.2 → litebuild-0.3.2}/LICENSE +0 -0
- {litebuild-0.2 → litebuild-0.3.2}/LiteBuild.egg-info/dependency_links.txt +0 -0
- {litebuild-0.2 → litebuild-0.3.2}/LiteBuild.egg-info/entry_points.txt +0 -0
- {litebuild-0.2 → litebuild-0.3.2}/LiteBuild.egg-info/requires.txt +0 -0
- {litebuild-0.2 → litebuild-0.3.2}/setup.cfg +0 -0
- {litebuild-0.2 → litebuild-0.3.2}/tests/test_build_engine.py +0 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
# build_engine.py
|
|
2
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from typing import List, Dict, Tuple, NamedTuple, Optional
|
|
10
|
+
|
|
11
|
+
import networkx as nx
|
|
12
|
+
from YMLEditor.yaml_reader import ConfigLoader
|
|
13
|
+
|
|
14
|
+
from LiteBuild.build_logger import BuildLogger, setup_logger, get_logger
|
|
15
|
+
from LiteBuild.command_generator import CommandGenerator
|
|
16
|
+
from LiteBuild.dependency_graph import DependencyGraph
|
|
17
|
+
from LiteBuild.schema import BUILD_SCHEMA, LiteBuildValidator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UpdateCode(IntEnum):
|
|
21
|
+
"""Enumeration for why a build step is considered outdated."""
|
|
22
|
+
UP_TO_DATE = 0
|
|
23
|
+
MISSING_OUTPUT = 1
|
|
24
|
+
NOT_TRACKED = 2
|
|
25
|
+
COMMAND_CHANGED = 3
|
|
26
|
+
INPUTS_CHANGED = 4
|
|
27
|
+
PARAMS_CHANGED = 5
|
|
28
|
+
NEWER_INPUT = 6
|
|
29
|
+
MISSING_INPUT = 7
|
|
30
|
+
STALE_TARGET = 8
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BuildStep(NamedTuple):
|
|
34
|
+
"""Represents a single step in the build plan."""
|
|
35
|
+
node_name: str
|
|
36
|
+
command: Dict
|
|
37
|
+
update_code: UpdateCode
|
|
38
|
+
context: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BuildPlan(NamedTuple):
|
|
42
|
+
"""Contains the full plan for an incremental build."""
|
|
43
|
+
steps_to_run: List[BuildStep]
|
|
44
|
+
steps_to_skip: List[BuildStep]
|
|
45
|
+
command_map: Dict
|
|
46
|
+
execution_graph: nx.DiGraph
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BuildEngine:
|
|
50
|
+
"""High-level facade for orchestrating the entire build system."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, config_data: dict, cli_vars: Optional[Dict] = None,
|
|
54
|
+
state_file: str = ".build_state.json"
|
|
55
|
+
):
|
|
56
|
+
"""Initializes the BuildEngine, merging command-line variables into the config."""
|
|
57
|
+
if cli_vars:
|
|
58
|
+
if "GENERAL" not in config_data:
|
|
59
|
+
config_data["GENERAL"] = {}
|
|
60
|
+
config_data["GENERAL"].update(cli_vars)
|
|
61
|
+
|
|
62
|
+
self.config = config_data
|
|
63
|
+
self.state_file = state_file
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_file(
|
|
67
|
+
cls, config_filepath: str, cli_vars: Optional[Dict] = None,
|
|
68
|
+
state_file: str = ".build_state.json"
|
|
69
|
+
):
|
|
70
|
+
"""Creates a BuildEngine instance from a configuration file."""
|
|
71
|
+
try:
|
|
72
|
+
loader = ConfigLoader(BUILD_SCHEMA, validator_class=LiteBuildValidator)
|
|
73
|
+
config_data = loader.read(
|
|
74
|
+
config_file=Path(config_filepath), normalize=True
|
|
75
|
+
)
|
|
76
|
+
return cls(config_data, cli_vars=cli_vars, state_file=state_file)
|
|
77
|
+
except (FileNotFoundError, ValueError) as e:
|
|
78
|
+
# Re-raise to be handled by the calling script (CLI or GUI)
|
|
79
|
+
raise e
|
|
80
|
+
|
|
81
|
+
def execute(self, final_step_name: str, profile_name: str = "", logger: BuildLogger = None):
|
|
82
|
+
"""
|
|
83
|
+
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
|
+
"""
|
|
90
|
+
if logger is None:
|
|
91
|
+
logger = get_logger()
|
|
92
|
+
setup_logger(logger)
|
|
93
|
+
|
|
94
|
+
logger.log(f"🔵 Executing build for step {final_step_name} using: '{profile_name}'")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
state_manager = BuildStateManager(self.state_file)
|
|
98
|
+
planner = BuildPlanner(self.config, state_manager.load_state())
|
|
99
|
+
plan = planner.plan_build(profile_name, final_step_name)
|
|
100
|
+
|
|
101
|
+
executor = BuildExecutor(state_manager, self.config)
|
|
102
|
+
success = executor.execute_plan(plan, logger)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
success = False
|
|
105
|
+
logger.log(f"{e}")
|
|
106
|
+
|
|
107
|
+
if success:
|
|
108
|
+
logger.log(f"\n✅ Build finished successfully.")
|
|
109
|
+
else:
|
|
110
|
+
logger.log(f"❌ Build failed for {final_step_name} using: '{profile_name}'")
|
|
111
|
+
|
|
112
|
+
def describe(self, profile_name: str) -> str:
|
|
113
|
+
"""Generates a Markdown description of the workflow for a given profile."""
|
|
114
|
+
reporter = BuildReporter(self.config)
|
|
115
|
+
return reporter.describe_workflow(profile_name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BuildPlanner:
|
|
119
|
+
"""
|
|
120
|
+
Analyzes the workflow and build state to create an incremental build plan.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, config: Dict, build_state: Dict):
|
|
124
|
+
self.config = config
|
|
125
|
+
self.build_state = build_state
|
|
126
|
+
self.logger = get_logger()
|
|
127
|
+
|
|
128
|
+
def _is_step_outdated(self, command: Dict) -> Tuple[UpdateCode, str]:
|
|
129
|
+
"""Checks a single step to see if it needs to be rebuilt, with detailed debug logging."""
|
|
130
|
+
output_path = command['output']
|
|
131
|
+
node_name = command.get('node_name', 'UnknownStep') # Assuming node_name is passed in command dict
|
|
132
|
+
|
|
133
|
+
self.logger.debug(f"\n--- Checking status of step '{node_name}' ---")
|
|
134
|
+
self.logger.debug(f" - Output file: '{output_path}'")
|
|
135
|
+
|
|
136
|
+
# 1. Check for output file existence
|
|
137
|
+
if not os.path.exists(output_path):
|
|
138
|
+
self.logger.debug(f" - RESULT: File does not exist. (MISSING_OUTPUT)")
|
|
139
|
+
return UpdateCode.MISSING_OUTPUT, os.path.basename(output_path)
|
|
140
|
+
self.logger.debug(f" - File exists: True")
|
|
141
|
+
|
|
142
|
+
# 2. Check if the output is tracked in the build state
|
|
143
|
+
stored_state = self.build_state.get(output_path)
|
|
144
|
+
if not stored_state:
|
|
145
|
+
self.logger.debug(f" - RESULT: Output file is not tracked in build state. (NOT_TRACKED)")
|
|
146
|
+
return UpdateCode.NOT_TRACKED, os.path.basename(output_path)
|
|
147
|
+
self.logger.debug(f" - Tracked in build state: True")
|
|
148
|
+
|
|
149
|
+
# 3. --- Hash comparisons ---
|
|
150
|
+
stored_hashes = stored_state.get("hashes", {})
|
|
151
|
+
current_hashes = command["hashes"]
|
|
152
|
+
|
|
153
|
+
if stored_hashes.get("command") != current_hashes.get("command"):
|
|
154
|
+
self.logger.debug(f" - RESULT: Command hash has changed. (COMMAND_CHANGED)")
|
|
155
|
+
return UpdateCode.COMMAND_CHANGED, ""
|
|
156
|
+
self.logger.debug(f" - Command hash: Match")
|
|
157
|
+
|
|
158
|
+
if stored_hashes.get("inputs") != current_hashes.get("inputs"):
|
|
159
|
+
self.logger.debug(f" - RESULT: Input file list hash has changed. (INPUTS_CHANGED)")
|
|
160
|
+
return UpdateCode.INPUTS_CHANGED, ""
|
|
161
|
+
self.logger.debug(f" - Input list hash: Match")
|
|
162
|
+
|
|
163
|
+
if stored_hashes.get("params") != current_hashes.get("params"):
|
|
164
|
+
self.logger.debug(f" - RESULT: Parameters hash has changed. (PARAMS_CHANGED)")
|
|
165
|
+
return UpdateCode.PARAMS_CHANGED, ""
|
|
166
|
+
self.logger.debug(f" - Parameters hash: Match")
|
|
167
|
+
|
|
168
|
+
# Slight pause to ensure files from previous step are ready
|
|
169
|
+
time.sleep(0.1)
|
|
170
|
+
|
|
171
|
+
# 4. --- Mtime (modification time) comparison ---
|
|
172
|
+
try:
|
|
173
|
+
last_build_mtime = stored_state.get('mtime', 0)
|
|
174
|
+
self.logger.debug(f" - Last build mtime for output: {last_build_mtime} ({time.ctime(last_build_mtime)})")
|
|
175
|
+
|
|
176
|
+
for input_file in command['input_files']:
|
|
177
|
+
self.logger.debug(f" - Checking input: '{input_file}'")
|
|
178
|
+
if not os.path.exists(input_file):
|
|
179
|
+
self.logger.debug(f" - RESULT: Input file does not exist. (MISSING_INPUT)")
|
|
180
|
+
raise FileNotFoundError(input_file)
|
|
181
|
+
|
|
182
|
+
input_mtime = os.path.getmtime(input_file)
|
|
183
|
+
self.logger.debug(f" - Input mtime: {input_mtime} ({time.ctime(input_mtime)})")
|
|
184
|
+
|
|
185
|
+
if input_mtime > last_build_mtime:
|
|
186
|
+
self.logger.debug(f" - RESULT: Input is newer than last build. (NEWER_INPUT)")
|
|
187
|
+
return UpdateCode.NEWER_INPUT, os.path.basename(input_file)
|
|
188
|
+
|
|
189
|
+
self.logger.debug(f" - All inputs are older than the last build.")
|
|
190
|
+
|
|
191
|
+
except FileNotFoundError as e:
|
|
192
|
+
return UpdateCode.MISSING_INPUT, os.path.basename(str(e))
|
|
193
|
+
|
|
194
|
+
# 5. --- Final Decision ---
|
|
195
|
+
self.logger.debug(f" - RESULT: Step is up-to-date. (UP_TO_DATE)")
|
|
196
|
+
return UpdateCode.UP_TO_DATE, ""
|
|
197
|
+
|
|
198
|
+
def plan_build(self, profile_name: str, final_step_name: str = None) -> BuildPlan:
|
|
199
|
+
command_map, execution_graph = self._generate_command_map_and_graph(
|
|
200
|
+
profile_name, final_step_name
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
for node, cmd in command_map.items():
|
|
204
|
+
cmd['node_name'] = node
|
|
205
|
+
|
|
206
|
+
initially_outdated = {}
|
|
207
|
+
build_order = list(nx.topological_sort(execution_graph))
|
|
208
|
+
for node_name in build_order:
|
|
209
|
+
command = command_map[node_name]
|
|
210
|
+
update_code, context = self._is_step_outdated(command)
|
|
211
|
+
if update_code != UpdateCode.UP_TO_DATE:
|
|
212
|
+
initially_outdated[node_name] = (update_code, context)
|
|
213
|
+
|
|
214
|
+
all_nodes_to_run = set(initially_outdated.keys())
|
|
215
|
+
for node_name in initially_outdated:
|
|
216
|
+
all_nodes_to_run.update(nx.descendants(execution_graph, node_name))
|
|
217
|
+
|
|
218
|
+
steps_to_run, steps_to_skip = [], []
|
|
219
|
+
for node_name in build_order:
|
|
220
|
+
step_command = command_map[node_name]
|
|
221
|
+
if node_name in all_nodes_to_run:
|
|
222
|
+
update_code, context = initially_outdated.get(
|
|
223
|
+
node_name, (UpdateCode.STALE_TARGET, "")
|
|
224
|
+
)
|
|
225
|
+
steps_to_run.append(BuildStep(node_name, step_command, update_code, context))
|
|
226
|
+
else:
|
|
227
|
+
steps_to_skip.append(BuildStep(node_name, step_command, UpdateCode.UP_TO_DATE, ""))
|
|
228
|
+
return BuildPlan(steps_to_run, steps_to_skip, command_map, execution_graph)
|
|
229
|
+
|
|
230
|
+
def _generate_command_map_and_graph(self, profile_name: str, final_step_name: str = None) -> \
|
|
231
|
+
Tuple[Dict, nx.DiGraph]:
|
|
232
|
+
"""Generates all commands and the dependency graph for a given profile."""
|
|
233
|
+
all_profiles = self.config.get("PROFILES", {})
|
|
234
|
+
profile_config = {}
|
|
235
|
+
if profile_name:
|
|
236
|
+
if profile_name in all_profiles:
|
|
237
|
+
profile_config = all_profiles[profile_name]
|
|
238
|
+
else:
|
|
239
|
+
available = "\n - ".join(all_profiles.keys())
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"profile '{profile_name}' not found. Available profiles are:\n - {available}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
graph_manager = DependencyGraph(self.config.get("WORKFLOW", {}))
|
|
245
|
+
execution_graph = graph_manager.get_execution_subgraph(final_step_name)
|
|
246
|
+
general_config = self.config.get("GENERAL", {})
|
|
247
|
+
command_gen = CommandGenerator(general_config, profile_config)
|
|
248
|
+
context = {"profile_name": profile_name, **general_config, **profile_config}
|
|
249
|
+
|
|
250
|
+
# Pre-process input files to create full paths automatically.
|
|
251
|
+
input_dir = context.get("INPUT_DIRECTORY")
|
|
252
|
+
input_basenames = context.get("INPUT_FILES")
|
|
253
|
+
|
|
254
|
+
if input_dir and input_basenames:
|
|
255
|
+
# Create the list of full paths
|
|
256
|
+
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
|
+
context['INPUT_FILES'] = full_paths
|
|
260
|
+
|
|
261
|
+
command_map, resolved_outputs = {}, {}
|
|
262
|
+
for node_name in nx.topological_sort(execution_graph):
|
|
263
|
+
node_data = execution_graph.nodes[node_name]
|
|
264
|
+
command_map[node_name] = command_gen.generate_for_node(
|
|
265
|
+
node_name, node_data, context, resolved_outputs
|
|
266
|
+
)
|
|
267
|
+
return command_map, execution_graph
|
|
268
|
+
|
|
269
|
+
class BuildExecutor:
|
|
270
|
+
"""Executes a build plan, running commands in parallel where possible."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, state_manager, config: Dict):
|
|
273
|
+
self.state_manager = state_manager
|
|
274
|
+
self.build_state = state_manager.load_state()
|
|
275
|
+
self.config = config
|
|
276
|
+
self.update_codes = {
|
|
277
|
+
UpdateCode.UP_TO_DATE: "(Up-to-date)", UpdateCode.MISSING_OUTPUT: "(Creating Output)",
|
|
278
|
+
UpdateCode.NOT_TRACKED: "(First build)",
|
|
279
|
+
UpdateCode.COMMAND_CHANGED: "(Command has changed)",
|
|
280
|
+
UpdateCode.INPUTS_CHANGED: "(Input file list has changed)",
|
|
281
|
+
UpdateCode.PARAMS_CHANGED: "(Parameters have changed)",
|
|
282
|
+
UpdateCode.NEWER_INPUT: "(Input '{context}' is newer)",
|
|
283
|
+
UpdateCode.MISSING_INPUT: "(Input '{context}' is missing)",
|
|
284
|
+
UpdateCode.STALE_TARGET: "(Target is stale)"
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def execute_plan(self, plan: BuildPlan, logger: BuildLogger) -> bool:
|
|
288
|
+
"""Executes the build plan, managing parallel execution and state."""
|
|
289
|
+
total_to_run = len(plan.steps_to_run)
|
|
290
|
+
finished_count = 0
|
|
291
|
+
|
|
292
|
+
for step in plan.steps_to_skip:
|
|
293
|
+
logger.log(f"Skipping '{step.node_name}' (up-to-date)")
|
|
294
|
+
|
|
295
|
+
if not plan.steps_to_run:
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
# Ask the logger for the information needed to initialize workers.
|
|
299
|
+
# This is polymorphic: a FileLogger provides info, a StreamLogger does not.
|
|
300
|
+
worker_init_info = logger.get_worker_init_info()
|
|
301
|
+
initializer, initargs = (worker_init_info if worker_init_info else (None, ()))
|
|
302
|
+
|
|
303
|
+
tasks_to_run_map = {s.node_name: s for s in plan.steps_to_run}
|
|
304
|
+
for generation in nx.topological_generations(plan.execution_graph):
|
|
305
|
+
tasks_this_generation = []
|
|
306
|
+
for node_name in generation:
|
|
307
|
+
if node_name in tasks_to_run_map:
|
|
308
|
+
step = tasks_to_run_map[node_name]
|
|
309
|
+
update_text = self.update_codes.get(step.update_code).format(
|
|
310
|
+
context=step.context
|
|
311
|
+
)
|
|
312
|
+
tasks_this_generation.append((step.node_name, step.command, update_text))
|
|
313
|
+
|
|
314
|
+
if not tasks_this_generation:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
max_workers = self.config.get("GENERAL", {}).get("MAX_WORKERS")
|
|
318
|
+
with ProcessPoolExecutor(
|
|
319
|
+
max_workers=max_workers, initializer=initializer, initargs=initargs
|
|
320
|
+
) as executor:
|
|
321
|
+
results = list(executor.map(self._run_single_command, tasks_this_generation))
|
|
322
|
+
|
|
323
|
+
halt_build = False
|
|
324
|
+
for status, result_data in results:
|
|
325
|
+
step_name = result_data.get('step_name', 'N/A')
|
|
326
|
+
if status == 'EXECUTED':
|
|
327
|
+
finished_count += 1
|
|
328
|
+
logger.log(f"✅ Finished step '{step_name}' [{finished_count}/{total_to_run}]")
|
|
329
|
+
self.build_state[result_data['output_path']] = {
|
|
330
|
+
"hashes": result_data['hashes'], "mtime": result_data['mtime']
|
|
331
|
+
}
|
|
332
|
+
elif status == 'FAILED':
|
|
333
|
+
halt_build = True
|
|
334
|
+
logger.log(f"❌ Build failed for Step '{step_name}'")
|
|
335
|
+
if halt_build:
|
|
336
|
+
self.state_manager.save_state(self.build_state)
|
|
337
|
+
return False
|
|
338
|
+
self.state_manager.save_state(self.build_state)
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _run_single_command(task: Tuple[str, Dict, str]) -> Tuple[str, Dict]:
|
|
343
|
+
"""Runs a command and streams all output to the configured log ."""
|
|
344
|
+
logger = get_logger()
|
|
345
|
+
step_name, command, update_text = task
|
|
346
|
+
output_path = command['output']
|
|
347
|
+
|
|
348
|
+
logger.log(f"\n▶️ Running step '{step_name}': {update_text}")
|
|
349
|
+
logger.log(f" [{step_name}] {command['cmd_string']}")
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
process = subprocess.Popen(
|
|
353
|
+
command['cmd_string'], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
354
|
+
text=True, encoding='utf-8', errors='replace'
|
|
355
|
+
)
|
|
356
|
+
for line in iter(process.stdout.readline, ''):
|
|
357
|
+
logger.log(f" [{step_name}] {line.strip()}")
|
|
358
|
+
return_code = process.wait()
|
|
359
|
+
if return_code != 0:
|
|
360
|
+
raise subprocess.CalledProcessError(return_code, "")
|
|
361
|
+
|
|
362
|
+
new_mtime = os.path.getmtime(output_path)
|
|
363
|
+
result_data = {
|
|
364
|
+
'step_name': step_name, 'output_path': output_path, 'hashes': command['hashes'],
|
|
365
|
+
'mtime': new_mtime
|
|
366
|
+
}
|
|
367
|
+
return 'EXECUTED', result_data
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.log(f"❌ Step '{step_name}' failed: {e}")
|
|
370
|
+
result_data = {'step_name': step_name}
|
|
371
|
+
return 'FAILED', result_data
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class BuildReporter:
|
|
375
|
+
"""Generates human-readable descriptions and diagrams of the workflow."""
|
|
376
|
+
|
|
377
|
+
def __init__(self, config: Dict):
|
|
378
|
+
self.config = config
|
|
379
|
+
|
|
380
|
+
def generate_mermaid_diagram(self, graph: nx.DiGraph) -> str:
|
|
381
|
+
"""Creates a Mermaid graph syntax string for the workflow."""
|
|
382
|
+
if not graph.nodes:
|
|
383
|
+
return "graph TD;\n Empty_Workflow[Workflow is empty];"
|
|
384
|
+
|
|
385
|
+
lines = ["graph TD;"]
|
|
386
|
+
|
|
387
|
+
# Build node styles based on dependency type (Source vs Process)
|
|
388
|
+
for node in graph.nodes():
|
|
389
|
+
# In topological sort, sources have in-degree 0
|
|
390
|
+
if graph.in_degree(node) == 0:
|
|
391
|
+
lines.append(f" {node}[{node}]:::source;")
|
|
392
|
+
else:
|
|
393
|
+
lines.append(f" {node}[{node}]:::process;")
|
|
394
|
+
|
|
395
|
+
# Edges
|
|
396
|
+
for u, v in graph.edges():
|
|
397
|
+
lines.append(f" {u} --> {v};")
|
|
398
|
+
|
|
399
|
+
# Styling Definitions
|
|
400
|
+
lines.append(" classDef source fill:#d4edda,stroke:#155724,color:#155724;")
|
|
401
|
+
lines.append(" classDef process fill:#e2e3e5,stroke:#383d41,color:#383d41;")
|
|
402
|
+
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
def describe_workflow(self, profile_name: str) -> str:
|
|
406
|
+
"""Generates a full Markdown report for the workflow."""
|
|
407
|
+
import datetime
|
|
408
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
409
|
+
project_name = self.config.get("GENERAL", {}).get("PROJECT_NAME", "LiteBuild Project")
|
|
410
|
+
|
|
411
|
+
# 1. Generate the Plan to resolve all variables
|
|
412
|
+
planner = BuildPlanner(self.config, {})
|
|
413
|
+
# Passing None for final_step gets the whole graph
|
|
414
|
+
plan = planner.plan_build(profile_name, None)
|
|
415
|
+
|
|
416
|
+
if not plan.command_map:
|
|
417
|
+
return f"# {project_name} - {profile_name}\nNo steps defined for this profile."
|
|
418
|
+
|
|
419
|
+
# Get the topologically sorted graph
|
|
420
|
+
graph = plan.execution_graph
|
|
421
|
+
build_order = list(nx.topological_sort(graph))
|
|
422
|
+
final_step = build_order[-1]
|
|
423
|
+
final_output = plan.command_map[final_step]['output']
|
|
424
|
+
|
|
425
|
+
# --- HEADER ---
|
|
426
|
+
lines = [
|
|
427
|
+
f"# {project_name} Pipeline Documentation",
|
|
428
|
+
"",
|
|
429
|
+
f"**Profile:** `{profile_name}` ",
|
|
430
|
+
f"**Generated:** {timestamp} ",
|
|
431
|
+
f"**Target Output:** `{final_output}`",
|
|
432
|
+
"",
|
|
433
|
+
"---",
|
|
434
|
+
""
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
# --- MERMAID DIAGRAM ---
|
|
438
|
+
lines.append("## Workflow Visualization")
|
|
439
|
+
lines.append("```mermaid")
|
|
440
|
+
lines.append(self.generate_mermaid_diagram(graph))
|
|
441
|
+
lines.append("```")
|
|
442
|
+
lines.append("")
|
|
443
|
+
|
|
444
|
+
# --- STEP DETAIL ---
|
|
445
|
+
lines.append("## Step-by-Step Guide")
|
|
446
|
+
|
|
447
|
+
workflow_def = self.config.get("WORKFLOW", {})
|
|
448
|
+
|
|
449
|
+
for node_name in build_order:
|
|
450
|
+
cmd_data = plan.command_map[node_name]
|
|
451
|
+
step_def = workflow_def.get(node_name, {})
|
|
452
|
+
|
|
453
|
+
description = step_def.get("DESCRIPTION", f"Executes rule: `{step_def.get('RULE', {}).get('NAME')}`")
|
|
454
|
+
|
|
455
|
+
lines.append(f"### {node_name}")
|
|
456
|
+
lines.append(f"_{description}_")
|
|
457
|
+
lines.append("")
|
|
458
|
+
|
|
459
|
+
# Inputs
|
|
460
|
+
if cmd_data['input_files']:
|
|
461
|
+
lines.append("**Inputs:**")
|
|
462
|
+
for f in cmd_data['input_files']:
|
|
463
|
+
lines.append(f"* `{f}`")
|
|
464
|
+
lines.append("")
|
|
465
|
+
|
|
466
|
+
# Output
|
|
467
|
+
lines.append(f"**Output:** `{cmd_data['output']}`")
|
|
468
|
+
lines.append("")
|
|
469
|
+
|
|
470
|
+
# Command
|
|
471
|
+
lines.append("**Command:**")
|
|
472
|
+
lines.append("```bash")
|
|
473
|
+
lines.append(cmd_data['cmd_string'])
|
|
474
|
+
lines.append("```")
|
|
475
|
+
lines.append("---")
|
|
476
|
+
|
|
477
|
+
return "\n".join(lines)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class BuildStateManager:
|
|
481
|
+
"""Manages loading and saving the .build_state.json file."""
|
|
482
|
+
|
|
483
|
+
def __init__(self, state_file_path: str):
|
|
484
|
+
self.state_file_path = state_file_path
|
|
485
|
+
|
|
486
|
+
def load_state(self) -> Dict:
|
|
487
|
+
"""Loads the build state from the JSON file."""
|
|
488
|
+
if not os.path.exists(self.state_file_path):
|
|
489
|
+
return {}
|
|
490
|
+
try:
|
|
491
|
+
with open(self.state_file_path, 'r') as f:
|
|
492
|
+
return json.load(f)
|
|
493
|
+
except (IOError, json.JSONDecodeError):
|
|
494
|
+
return {}
|
|
495
|
+
|
|
496
|
+
def save_state(self, state: Dict):
|
|
497
|
+
"""Saves the build state to the JSON file."""
|
|
498
|
+
try:
|
|
499
|
+
with open(self.state_file_path, 'w') as f:
|
|
500
|
+
json.dump(state, f, indent=2)
|
|
501
|
+
except IOError as e:
|
|
502
|
+
raise IOError(f"Could not write to state file '{self.state_file_path}': {e}")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# --- Worker initializer accepts a logger object ---
|
|
506
|
+
def setup_worker_logger(logger: BuildLogger):
|
|
507
|
+
"""Initializes the logger for a worker process."""
|
|
508
|
+
setup_logger(logger)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# build_logger.py
|
|
2
|
+
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from enum import IntEnum # <-- ADDED
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional, Tuple, Callable, Any, Union, TextIO
|
|
8
|
+
|
|
9
|
+
from filelock import FileLock
|
|
10
|
+
|
|
11
|
+
# Global instance for each process to hold its logger.
|
|
12
|
+
_logger_instance = None
|
|
13
|
+
|
|
14
|
+
# --- Define log levels for filtering ---
|
|
15
|
+
class LogLevel(IntEnum):
|
|
16
|
+
DEBUG = 10
|
|
17
|
+
INFO = 20
|
|
18
|
+
WARNING = 30
|
|
19
|
+
ERROR = 40
|
|
20
|
+
|
|
21
|
+
def initialize_file_logger_for_worker(log_file_path_str: str, log_level_name: str):
|
|
22
|
+
"""Creates and sets up a BuildLogger instance in a new process."""
|
|
23
|
+
global _logger_instance
|
|
24
|
+
log_level = LogLevel[log_level_name.upper()]
|
|
25
|
+
_logger_instance = BuildLogger(Path(log_file_path_str), log_level=log_level)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BuildLogger:
|
|
29
|
+
"""A unified logger that supports levels and writes to a file or stream."""
|
|
30
|
+
def __init__(self, output: Union[str, Path, Any], log_level: LogLevel = LogLevel.INFO):
|
|
31
|
+
"""
|
|
32
|
+
Initializes the logger.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
output: A file path or a text stream object.
|
|
36
|
+
log_level: The minimum level of messages to record.
|
|
37
|
+
"""
|
|
38
|
+
self.output_target = output
|
|
39
|
+
self.level = log_level # Store the configured level
|
|
40
|
+
self.is_file_based = isinstance(output, (str, Path))
|
|
41
|
+
self.log_file_handle: Any
|
|
42
|
+
self.lock = None
|
|
43
|
+
|
|
44
|
+
if self.is_file_based:
|
|
45
|
+
log_file = Path(output)
|
|
46
|
+
# Use 'a' to append
|
|
47
|
+
self.log_file_handle = open(log_file, 'a', encoding='utf-8')
|
|
48
|
+
self.lock = FileLock(log_file.with_suffix(".lock"))
|
|
49
|
+
|
|
50
|
+
# --- FIX: Duck typing check instead of strict isinstance(TextIO) ---
|
|
51
|
+
elif hasattr(output, 'write') and hasattr(output, 'flush'):
|
|
52
|
+
self.log_file_handle = output
|
|
53
|
+
self.lock = nullcontext()
|
|
54
|
+
else:
|
|
55
|
+
# Fallback for None or invalid stdout in GUI apps
|
|
56
|
+
# If we really can't write, point to a dummy object to prevent crashes
|
|
57
|
+
if output is None:
|
|
58
|
+
import os
|
|
59
|
+
self.log_file_handle = open(os.devnull, 'w')
|
|
60
|
+
self.lock = nullcontext()
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"Invalid Logger output: {type(output)}")
|
|
63
|
+
|
|
64
|
+
def log(self, message: str, level: LogLevel = LogLevel.INFO):
|
|
65
|
+
"""
|
|
66
|
+
Writes a message to the output if its level is sufficient.
|
|
67
|
+
This is the core dispatcher for all logging methods.
|
|
68
|
+
"""
|
|
69
|
+
# --- Filtering logic ---
|
|
70
|
+
if level < self.level:
|
|
71
|
+
return # Skip messages below the configured threshold
|
|
72
|
+
|
|
73
|
+
formatted_message = f"{message}\n"
|
|
74
|
+
with self.lock:
|
|
75
|
+
self.log_file_handle.write(formatted_message)
|
|
76
|
+
self.log_file_handle.flush()
|
|
77
|
+
|
|
78
|
+
# --- Level-specific helper methods ---
|
|
79
|
+
def debug(self, message: str):
|
|
80
|
+
"""Logs a message with DEBUG level."""
|
|
81
|
+
self.log(message, level=LogLevel.DEBUG)
|
|
82
|
+
|
|
83
|
+
def info(self, message: str):
|
|
84
|
+
"""Logs a message with INFO level."""
|
|
85
|
+
self.log(message, level=LogLevel.INFO)
|
|
86
|
+
|
|
87
|
+
def warning(self, message: str):
|
|
88
|
+
"""Logs a message with WARNING level."""
|
|
89
|
+
self.log(f"⚠️ WARNING: {message}", level=LogLevel.WARNING)
|
|
90
|
+
|
|
91
|
+
def error(self, message: str):
|
|
92
|
+
"""Logs a message with ERROR level."""
|
|
93
|
+
self.log(f"❌ ERROR: {message}", level=LogLevel.ERROR)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_worker_init_info(self) -> Optional[Tuple[Callable, Tuple[Any, ...]]]:
|
|
97
|
+
"""
|
|
98
|
+
Returns worker initialization info ONLY if this is a file-based logger.
|
|
99
|
+
"""
|
|
100
|
+
if self.is_file_based:
|
|
101
|
+
# --- Pass the log level name to the worker ---
|
|
102
|
+
return initialize_file_logger_for_worker, (str(self.output_target), self.level.name)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def setup_logger(logger: BuildLogger):
|
|
107
|
+
"""Initializes the singleton BuildLogger for the current process."""
|
|
108
|
+
global _logger_instance
|
|
109
|
+
_logger_instance = logger
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_logger() -> BuildLogger:
|
|
113
|
+
"""Retrieves the current process's logger instance."""
|
|
114
|
+
global _logger_instance
|
|
115
|
+
if _logger_instance is None:
|
|
116
|
+
# Default to INFO level if not explicitly set up
|
|
117
|
+
_logger_instance = BuildLogger(sys.stdout, log_level=LogLevel.INFO)
|
|
118
|
+
return _logger_instance
|