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