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.
@@ -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