scriptplan 0.9.0__py3-none-any.whl

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.
Files changed (49) hide show
  1. scriptplan/__init__.py +22 -0
  2. scriptplan/cli/__init__.py +7 -0
  3. scriptplan/cli/main.py +546 -0
  4. scriptplan/core/__init__.py +0 -0
  5. scriptplan/core/account.py +125 -0
  6. scriptplan/core/allocation.py +69 -0
  7. scriptplan/core/booking.py +39 -0
  8. scriptplan/core/journal.py +377 -0
  9. scriptplan/core/leave.py +14 -0
  10. scriptplan/core/limits.py +354 -0
  11. scriptplan/core/project.py +924 -0
  12. scriptplan/core/property.py +1290 -0
  13. scriptplan/core/resource.py +198 -0
  14. scriptplan/core/resource_scenario.py +711 -0
  15. scriptplan/core/scenario.py +5 -0
  16. scriptplan/core/scenario_data.py +39 -0
  17. scriptplan/core/shift.py +71 -0
  18. scriptplan/core/task.py +77 -0
  19. scriptplan/core/task_scenario.py +1515 -0
  20. scriptplan/core/timesheet.py +457 -0
  21. scriptplan/core/working_hours.py +231 -0
  22. scriptplan/parser/__init__.py +0 -0
  23. scriptplan/parser/macro_processor.py +264 -0
  24. scriptplan/parser/tjp.lark +412 -0
  25. scriptplan/parser/tjp_parser.py +1904 -0
  26. scriptplan/py.typed +0 -0
  27. scriptplan/report/__init__.py +75 -0
  28. scriptplan/report/html_generator.py +477 -0
  29. scriptplan/report/report.py +466 -0
  30. scriptplan/report/report_base.py +397 -0
  31. scriptplan/report/report_context.py +248 -0
  32. scriptplan/report/resource_report.py +341 -0
  33. scriptplan/report/table_report.py +693 -0
  34. scriptplan/report/task_report.py +362 -0
  35. scriptplan/report/text_report.py +172 -0
  36. scriptplan/scheduler/__init__.py +0 -0
  37. scriptplan/scheduler/batch_processor.py +238 -0
  38. scriptplan/scheduler/scoreboard.py +120 -0
  39. scriptplan/utils/__init__.py +0 -0
  40. scriptplan/utils/data_cache.py +46 -0
  41. scriptplan/utils/logger.py +243 -0
  42. scriptplan/utils/message_handler.py +515 -0
  43. scriptplan/utils/time.py +195 -0
  44. scriptplan-0.9.0.dist-info/METADATA +161 -0
  45. scriptplan-0.9.0.dist-info/RECORD +49 -0
  46. scriptplan-0.9.0.dist-info/WHEEL +5 -0
  47. scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
  48. scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
  49. scriptplan-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,238 @@
1
+ """BatchProcessor module for parallel job execution.
2
+
3
+ The BatchProcessor class can be used to run code blocks of the program as
4
+ separate processes. Multiple pieces of code can be submitted to be executed
5
+ in parallel. The number of CPU cores to use is limited at object creation time.
6
+ """
7
+
8
+ import threading
9
+ import multiprocessing
10
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
11
+ from typing import Callable, Any, Optional, List, Dict
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class JobInfo:
17
+ """Storage container for batch job related information.
18
+
19
+ Contains job id, process id, stdout/stderr data and return value.
20
+ """
21
+
22
+ job_id: int
23
+ func: Callable
24
+ tag: Any = None
25
+ pid: Optional[int] = None
26
+ ret_val: Optional[int] = None
27
+ stdout: str = ''
28
+ stderr: str = ''
29
+ args: tuple = field(default_factory=tuple)
30
+ kwargs: dict = field(default_factory=dict)
31
+
32
+ @property
33
+ def jobId(self):
34
+ """Alias for Ruby compatibility."""
35
+ return self.job_id
36
+
37
+ @property
38
+ def retVal(self):
39
+ """Alias for Ruby compatibility."""
40
+ return self.ret_val
41
+
42
+
43
+ def _worker_function(func, args, kwargs):
44
+ """Worker function that runs in subprocess and returns result."""
45
+ try:
46
+ result = func(*args, **kwargs)
47
+ return (0, result, '', '')
48
+ except Exception as e:
49
+ import traceback
50
+ return (1, None, '', traceback.format_exc())
51
+
52
+
53
+ class BatchProcessor:
54
+ """Run code blocks in parallel processes.
55
+
56
+ Submitted jobs are queued and scheduled to the given number of CPUs.
57
+ Usage:
58
+ 1. Create a BatchProcessor object with max CPU cores
59
+ 2. Use queue() to submit jobs
60
+ 3. Use wait() to wait for completion and process results
61
+ """
62
+
63
+ def __init__(self, max_cpu_cores: int = None):
64
+ """Create a BatchProcessor object.
65
+
66
+ Args:
67
+ max_cpu_cores: Maximum number of simultaneous processes.
68
+ Defaults to CPU count.
69
+ """
70
+ if max_cpu_cores is None:
71
+ max_cpu_cores = multiprocessing.cpu_count()
72
+ self._max_cpu_cores = max_cpu_cores
73
+
74
+ self._to_run_queue: List[JobInfo] = []
75
+ self._running_jobs: Dict[int, JobInfo] = {}
76
+ self._completed_jobs: List[JobInfo] = []
77
+
78
+ self._lock = threading.Lock()
79
+ self._jobs_in = 0
80
+ self._jobs_out = 0
81
+
82
+ self._executor = None
83
+
84
+ @property
85
+ def maxCpuCores(self):
86
+ """Return the maximum number of CPU cores to use."""
87
+ return self._max_cpu_cores
88
+
89
+ def queue(self, tag: Any = None, func: Callable = None, *args, **kwargs):
90
+ """Add a new job to the job queue.
91
+
92
+ Args:
93
+ tag: Optional data to identify the job upon completion.
94
+ func: The function to execute in a separate process.
95
+ *args: Positional arguments to pass to the function.
96
+ **kwargs: Keyword arguments to pass to the function.
97
+ """
98
+ with self._lock:
99
+ if self._jobs_out > 0:
100
+ raise RuntimeError("You cannot call queue() while wait() is running!")
101
+
102
+ job = JobInfo(
103
+ job_id=self._jobs_in,
104
+ func=func,
105
+ tag=tag,
106
+ args=args,
107
+ kwargs=kwargs
108
+ )
109
+ self._jobs_in += 1
110
+ self._to_run_queue.append(job)
111
+
112
+ def wait(self, callback: Callable[[JobInfo], None] = None):
113
+ """Wait for all jobs to complete.
114
+
115
+ Args:
116
+ callback: Optional function called with each JobInfo as jobs complete.
117
+ """
118
+ if self._jobs_in == 0:
119
+ return
120
+
121
+ # Create executor
122
+ self._executor = ProcessPoolExecutor(max_workers=self._max_cpu_cores)
123
+
124
+ try:
125
+ # Submit all jobs
126
+ futures = {}
127
+ for job in self._to_run_queue:
128
+ future = self._executor.submit(
129
+ _worker_function, job.func, job.args, job.kwargs
130
+ )
131
+ futures[future] = job
132
+
133
+ # Wait for completion and process results
134
+ for future in as_completed(futures):
135
+ job = futures[future]
136
+ try:
137
+ ret_code, result, stdout, stderr = future.result()
138
+ job.ret_val = ret_code
139
+ job.stdout = stdout
140
+ job.stderr = stderr
141
+ except Exception as e:
142
+ job.ret_val = 1
143
+ job.stderr = str(e)
144
+
145
+ self._jobs_out += 1
146
+ self._completed_jobs.append(job)
147
+
148
+ if callback:
149
+ callback(job)
150
+
151
+ finally:
152
+ self._executor.shutdown(wait=True)
153
+ self._executor = None
154
+
155
+ # Reset for reuse
156
+ self._to_run_queue.clear()
157
+ self._running_jobs.clear()
158
+ self._completed_jobs.clear()
159
+ self._jobs_in = 0
160
+ self._jobs_out = 0
161
+
162
+ def cancel(self):
163
+ """Cancel all pending jobs."""
164
+ if self._executor:
165
+ self._executor.shutdown(wait=False, cancel_futures=True)
166
+ self._executor = None
167
+
168
+
169
+ class ThreadBatchProcessor:
170
+ """Thread-based batch processor for lighter workloads.
171
+
172
+ Uses threads instead of processes, suitable for I/O-bound tasks.
173
+ """
174
+
175
+ def __init__(self, max_threads: int = None):
176
+ """Create a ThreadBatchProcessor object.
177
+
178
+ Args:
179
+ max_threads: Maximum number of simultaneous threads.
180
+ Defaults to CPU count * 5.
181
+ """
182
+ if max_threads is None:
183
+ max_threads = multiprocessing.cpu_count() * 5
184
+ self._max_threads = max_threads
185
+
186
+ self._to_run_queue: List[JobInfo] = []
187
+ self._jobs_in = 0
188
+ self._jobs_out = 0
189
+ self._lock = threading.Lock()
190
+ self._executor = None
191
+
192
+ def queue(self, tag: Any = None, func: Callable = None, *args, **kwargs):
193
+ """Add a new job to the job queue."""
194
+ with self._lock:
195
+ job = JobInfo(
196
+ job_id=self._jobs_in,
197
+ func=func,
198
+ tag=tag,
199
+ args=args,
200
+ kwargs=kwargs
201
+ )
202
+ self._jobs_in += 1
203
+ self._to_run_queue.append(job)
204
+
205
+ def wait(self, callback: Callable[[JobInfo], None] = None):
206
+ """Wait for all jobs to complete."""
207
+ if self._jobs_in == 0:
208
+ return
209
+
210
+ self._executor = ThreadPoolExecutor(max_workers=self._max_threads)
211
+
212
+ try:
213
+ futures = {}
214
+ for job in self._to_run_queue:
215
+ future = self._executor.submit(job.func, *job.args, **job.kwargs)
216
+ futures[future] = job
217
+
218
+ for future in as_completed(futures):
219
+ job = futures[future]
220
+ try:
221
+ result = future.result()
222
+ job.ret_val = 0
223
+ except Exception as e:
224
+ job.ret_val = 1
225
+ job.stderr = str(e)
226
+
227
+ self._jobs_out += 1
228
+
229
+ if callback:
230
+ callback(job)
231
+
232
+ finally:
233
+ self._executor.shutdown(wait=True)
234
+ self._executor = None
235
+
236
+ self._to_run_queue.clear()
237
+ self._jobs_in = 0
238
+ self._jobs_out = 0
@@ -0,0 +1,120 @@
1
+ import math
2
+ from scriptplan.utils.time import TimeInterval
3
+
4
+ class Scoreboard:
5
+ def __init__(self, start, end, granularity, init_val=None):
6
+ self.startDate = start
7
+ self.endDate = end
8
+ self.resolution = granularity
9
+
10
+ # Calculate size
11
+ # Ruby: ((endDate - startDate) / resolution).ceil + 1
12
+ diff = (end - start).total_seconds() if hasattr(end - start, 'total_seconds') else (end - start)
13
+ self.size = math.ceil(diff / granularity) + 1
14
+
15
+ self.clear(init_val)
16
+
17
+ def clear(self, init_val=None):
18
+ self.sb = [init_val] * self.size
19
+
20
+ def idxToDate(self, idx, forceIntoProject=False):
21
+ if forceIntoProject:
22
+ if idx < 0:
23
+ return self.startDate
24
+ if idx >= self.size:
25
+ return self.endDate
26
+ elif idx < 0 or idx >= self.size:
27
+ raise IndexError(f"Index {idx} is out of scoreboard range ({self.size - 1})")
28
+
29
+ from datetime import timedelta
30
+ return self.startDate + timedelta(seconds=idx * self.resolution)
31
+
32
+ def dateToIdx(self, date, forceIntoProject=True):
33
+ diff = (date - self.startDate).total_seconds() if hasattr(date - self.startDate, 'total_seconds') else (date - self.startDate)
34
+ idx = int(diff / self.resolution)
35
+
36
+ if forceIntoProject:
37
+ if idx < 0: return 0
38
+ if idx >= self.size: return self.size - 1
39
+ elif idx < 0 or idx >= self.size:
40
+ raise IndexError(f"Date {date} is out of project time range ({self.startDate} - {self.endDate})")
41
+
42
+ return idx
43
+
44
+ def each(self, startIdx=0, endIdx=None):
45
+ if endIdx is None:
46
+ endIdx = self.size
47
+
48
+ if startIdx != 0 or endIdx != self.size:
49
+ for i in range(startIdx, endIdx):
50
+ yield self.sb[i]
51
+ else:
52
+ for entry in self.sb:
53
+ yield entry
54
+
55
+ def each_index(self):
56
+ for i in range(len(self.sb)):
57
+ yield i
58
+
59
+ def collect(self, func):
60
+ for i in range(len(self.sb)):
61
+ self.sb[i] = func(self.sb[i])
62
+
63
+ def __getitem__(self, idx):
64
+ return self.sb[idx]
65
+
66
+ def __setitem__(self, idx, value):
67
+ self.sb[idx] = value
68
+
69
+ def get(self, date):
70
+ return self.sb[self.dateToIdx(date)]
71
+
72
+ def set(self, date, value):
73
+ self.sb[self.dateToIdx(date)] = value
74
+
75
+ def collectIntervals(self, iv, minDuration, predicate):
76
+ startIdx = self.dateToIdx(iv.start)
77
+ endIdx = self.dateToIdx(iv.end)
78
+ sIdx = startIdx
79
+ eIdx = endIdx
80
+
81
+ minDurationSlots = int(minDuration / self.resolution)
82
+ if minDurationSlots <= 0:
83
+ minDurationSlots = 1
84
+
85
+ startIdx -= minDurationSlots
86
+ if startIdx < 0: startIdx = 0
87
+ endIdx += minDurationSlots
88
+ if endIdx > self.size - 1: endIdx = self.size - 1
89
+
90
+ intervals = []
91
+ duration = 0
92
+ start = 0
93
+
94
+ idx = startIdx
95
+ while idx <= endIdx:
96
+ # yield/predicate check
97
+ val = self.sb[idx] if idx < len(self.sb) else None # Boundary check
98
+ if predicate(val) and idx < endIdx:
99
+ if start == 0:
100
+ start = idx
101
+ duration += 1
102
+ else:
103
+ if duration > 0:
104
+ if duration >= minDurationSlots:
105
+ if start < sIdx: start = sIdx
106
+ current_idx = idx
107
+ if current_idx > eIdx: current_idx = eIdx
108
+
109
+ intervals.append(TimeInterval(self.idxToDate(start), self.idxToDate(current_idx)))
110
+ duration = 0
111
+ start = 0
112
+ idx += 1
113
+
114
+ return intervals
115
+
116
+ def __iter__(self):
117
+ return iter(self.sb)
118
+
119
+ def __len__(self):
120
+ return self.size
File without changes
@@ -0,0 +1,46 @@
1
+ class DataCache:
2
+ _instance = None
3
+
4
+ def __init__(self):
5
+ self._cache = {}
6
+
7
+ @classmethod
8
+ def instance(cls):
9
+ if cls._instance is None:
10
+ cls._instance = DataCache()
11
+ return cls._instance
12
+
13
+ def flush(self):
14
+ self._cache = {}
15
+
16
+ def cached(self, obj, tag, *args, **kwargs):
17
+ # Simple caching implementation
18
+ # Key based on object id, tag, and args
19
+ key = (id(obj), tag, args, tuple(kwargs.items()))
20
+ if key in self._cache:
21
+ return self._cache[key]
22
+
23
+ # If block is passed?
24
+ # In Python, we can't pass a block like Ruby.
25
+ # We expect the last argument or a specific argument to be a callable if used like Ruby's block.
26
+ # But here the caller is likely doing: @dCache.cached(...) do ... end
27
+ # In Python: dCache.cached(..., lambda: ...)
28
+ # So the last arg might be the function to execute.
29
+
30
+ # However, treeSumR implementation:
31
+ # @dCache.cached(self, cacheTag, startIdx, endIdx, *args) do ... end
32
+
33
+ # In Python `ResourceScenario.treeSumR`:
34
+ # self.dCache.cached(self, cacheTag, startIdx, endIdx, *args, lambda: ...) ?
35
+
36
+ # I haven't implemented treeSumR in Python yet.
37
+ # If I did, I would pass a callable.
38
+
39
+ # Let's assume the last argument is the callable if it's a function.
40
+ # Or better, explicit 'calculator' argument.
41
+
42
+ # But since I haven't implemented treeSumR fully in ResourceScenario, this is future proofing.
43
+ pass
44
+
45
+ class FileList(list):
46
+ pass
@@ -0,0 +1,243 @@
1
+ """Log module implementing segmented execution traces.
2
+
3
+ The Log class implements a filter for segmented execution traces. The
4
+ trace messages are filtered based on their segment name and the nesting
5
+ level of the segments. The class uses a Singleton pattern.
6
+ """
7
+
8
+ import sys
9
+ import threading
10
+ from typing import List, Callable, Optional
11
+
12
+
13
+ class ANSIColor:
14
+ """ANSI color codes for terminal output."""
15
+ GREEN = '\033[32m'
16
+ RED = '\033[31m'
17
+ YELLOW = '\033[33m'
18
+ BLUE = '\033[34m'
19
+ RESET = '\033[0m'
20
+
21
+ @classmethod
22
+ def green(cls, text: str) -> str:
23
+ return f"{cls.GREEN}{text}{cls.RESET}"
24
+
25
+ @classmethod
26
+ def red(cls, text: str) -> str:
27
+ return f"{cls.RED}{text}{cls.RESET}"
28
+
29
+ @classmethod
30
+ def yellow(cls, text: str) -> str:
31
+ return f"{cls.YELLOW}{text}{cls.RESET}"
32
+
33
+ @classmethod
34
+ def blue(cls, text: str) -> str:
35
+ return f"{cls.BLUE}{text}{cls.RESET}"
36
+
37
+
38
+ class Log:
39
+ """Singleton class for segmented execution trace logging.
40
+
41
+ The trace messages are filtered based on their segment name and the nesting
42
+ level of the segments.
43
+ """
44
+
45
+ _instance = None
46
+ _lock = threading.Lock()
47
+
48
+ # Class-level variables (equivalent to Ruby's @@)
49
+ _level = 0
50
+ _stack: List[str] = []
51
+ _segments: List[str] = []
52
+ _silent = True
53
+ _progress = 0
54
+ _progressMeter = ''
55
+
56
+ def __new__(cls):
57
+ if cls._instance is None:
58
+ with cls._lock:
59
+ if cls._instance is None:
60
+ cls._instance = super().__new__(cls)
61
+ return cls._instance
62
+
63
+ @classmethod
64
+ def get_level(cls) -> int:
65
+ """Get the current log level."""
66
+ return cls._level
67
+
68
+ @classmethod
69
+ def set_level(cls, level: int):
70
+ """Set the maximum nesting level that should be shown.
71
+
72
+ Segments with a nesting level greater than level will be silently dropped.
73
+ """
74
+ cls._level = level
75
+
76
+ # Property-style accessors
77
+ level = property(lambda cls: cls._level)
78
+
79
+ @classmethod
80
+ def get_segments(cls) -> List[str]:
81
+ """Get the current segment filter list."""
82
+ return cls._segments
83
+
84
+ @classmethod
85
+ def set_segments(cls, segments: List[str]):
86
+ """Set the segment filter list.
87
+
88
+ Messages not in these segments will be ignored. Messages from segments
89
+ that are nested into the shown segments will be shown for the next
90
+ _level nested segments.
91
+ """
92
+ cls._segments = segments
93
+
94
+ @classmethod
95
+ def get_silent(cls) -> bool:
96
+ """Get the silent mode status."""
97
+ return cls._silent
98
+
99
+ @classmethod
100
+ def set_silent(cls, silent: bool):
101
+ """Set silent mode. If True, progress information will not be shown."""
102
+ cls._silent = silent
103
+
104
+ @classmethod
105
+ def enter(cls, segment: str, message: str):
106
+ """Open a new segment.
107
+
108
+ Args:
109
+ segment: The name of the segment.
110
+ message: A description of the segment.
111
+ """
112
+ if cls._level == 0:
113
+ return
114
+
115
+ cls._stack.append(segment)
116
+ cls.msg(lambda: f">> [{segment}] {message}")
117
+
118
+ @classmethod
119
+ def exit(cls, segment: str, message: Optional[str] = None):
120
+ """Close an open segment.
121
+
122
+ Will search the stack of open segments for a segment with that name
123
+ and will close all nested segments as well.
124
+
125
+ Args:
126
+ segment: The name of the segment to close.
127
+ message: Optional exit message.
128
+ """
129
+ if cls._level == 0:
130
+ return
131
+
132
+ if message:
133
+ cls.msg(lambda: f"<< [{segment}] {message}")
134
+
135
+ if segment in cls._stack:
136
+ while cls._stack:
137
+ m = cls._stack.pop()
138
+ if m == segment:
139
+ break
140
+
141
+ @classmethod
142
+ def msg(cls, message_func: Callable[[], str]):
143
+ """Show a log message within the currently active segment.
144
+
145
+ The message is the result of the passed callable. The callable will
146
+ only be evaluated if the message will actually be shown.
147
+
148
+ Args:
149
+ message_func: A callable that returns the message string.
150
+ """
151
+ if cls._level == 0:
152
+ return
153
+
154
+ offset = 0
155
+ if cls._segments:
156
+ showMessage = False
157
+ for segment in cls._stack:
158
+ if segment in cls._segments:
159
+ offset = cls._stack.index(segment)
160
+ showMessage = True
161
+ break
162
+ if not showMessage:
163
+ return
164
+
165
+ if len(cls._stack) - offset < cls._level:
166
+ indent = ' ' * (len(cls._stack) - offset)
167
+ print(indent + message_func(), file=sys.stderr)
168
+
169
+ @classmethod
170
+ def status(cls, message: str):
171
+ """Print out a status message unless in silent mode."""
172
+ if cls._silent:
173
+ return
174
+ print(message)
175
+
176
+ @classmethod
177
+ def startProgressMeter(cls, text: str):
178
+ """Start the progress meter display or change the info text.
179
+
180
+ While the meter is active, the text cursor is always returned to
181
+ the start of the same line.
182
+ """
183
+ if cls._silent:
184
+ return
185
+
186
+ maxlen = 60
187
+ text = text.ljust(maxlen)
188
+ if len(text) > maxlen:
189
+ text = text[:maxlen]
190
+ cls._progressMeter = text
191
+ print(f"{cls._progressMeter} ...", end='\r')
192
+ sys.stdout.flush()
193
+
194
+ @classmethod
195
+ def stopProgressMeter(cls):
196
+ """Set the progress meter status to 'done' and move to the next line."""
197
+ if cls._silent:
198
+ return
199
+ print(f"{cls._progressMeter} [ {ANSIColor.green('Done')} ]")
200
+
201
+ @classmethod
202
+ def activity(cls):
203
+ """Update the progress indicator to the next symbol.
204
+
205
+ May only be called after startProgressMeter.
206
+ """
207
+ if cls._silent:
208
+ return
209
+
210
+ indicator = ['-', '\\', '|', '/']
211
+ cls._progress = (cls._progress + 1) % len(indicator)
212
+ print(f"{cls._progressMeter} [{indicator[cls._progress]}]", end='\r')
213
+ sys.stdout.flush()
214
+
215
+ @classmethod
216
+ def progress(cls, percent: float):
217
+ """Update the progress bar to the given percent completion.
218
+
219
+ May only be called after startProgressMeter.
220
+
221
+ Args:
222
+ percent: Completion value between 0.0 and 1.0.
223
+ """
224
+ if cls._silent:
225
+ return
226
+
227
+ percent = max(0.0, min(1.0, percent))
228
+ cls._progress = percent
229
+
230
+ length = 16
231
+ full = int(length * percent)
232
+ bar = '=' * full + ' ' * (length - full)
233
+ label = f"{int(percent * 100.0)}%"
234
+ start = length // 2 - len(label) // 2
235
+ bar = bar[:start] + label + bar[start + len(label):]
236
+ print(f"{cls._progressMeter} [{ANSIColor.green(bar)}]", end='\r')
237
+ sys.stdout.flush()
238
+
239
+
240
+ # Convenience function to get the singleton instance
241
+ def get_logger() -> Log:
242
+ """Return the Log singleton instance."""
243
+ return Log()