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.
- scriptplan/__init__.py +22 -0
- scriptplan/cli/__init__.py +7 -0
- scriptplan/cli/main.py +546 -0
- scriptplan/core/__init__.py +0 -0
- scriptplan/core/account.py +125 -0
- scriptplan/core/allocation.py +69 -0
- scriptplan/core/booking.py +39 -0
- scriptplan/core/journal.py +377 -0
- scriptplan/core/leave.py +14 -0
- scriptplan/core/limits.py +354 -0
- scriptplan/core/project.py +924 -0
- scriptplan/core/property.py +1290 -0
- scriptplan/core/resource.py +198 -0
- scriptplan/core/resource_scenario.py +711 -0
- scriptplan/core/scenario.py +5 -0
- scriptplan/core/scenario_data.py +39 -0
- scriptplan/core/shift.py +71 -0
- scriptplan/core/task.py +77 -0
- scriptplan/core/task_scenario.py +1515 -0
- scriptplan/core/timesheet.py +457 -0
- scriptplan/core/working_hours.py +231 -0
- scriptplan/parser/__init__.py +0 -0
- scriptplan/parser/macro_processor.py +264 -0
- scriptplan/parser/tjp.lark +412 -0
- scriptplan/parser/tjp_parser.py +1904 -0
- scriptplan/py.typed +0 -0
- scriptplan/report/__init__.py +75 -0
- scriptplan/report/html_generator.py +477 -0
- scriptplan/report/report.py +466 -0
- scriptplan/report/report_base.py +397 -0
- scriptplan/report/report_context.py +248 -0
- scriptplan/report/resource_report.py +341 -0
- scriptplan/report/table_report.py +693 -0
- scriptplan/report/task_report.py +362 -0
- scriptplan/report/text_report.py +172 -0
- scriptplan/scheduler/__init__.py +0 -0
- scriptplan/scheduler/batch_processor.py +238 -0
- scriptplan/scheduler/scoreboard.py +120 -0
- scriptplan/utils/__init__.py +0 -0
- scriptplan/utils/data_cache.py +46 -0
- scriptplan/utils/logger.py +243 -0
- scriptplan/utils/message_handler.py +515 -0
- scriptplan/utils/time.py +195 -0
- scriptplan-0.9.0.dist-info/METADATA +161 -0
- scriptplan-0.9.0.dist-info/RECORD +49 -0
- scriptplan-0.9.0.dist-info/WHEEL +5 -0
- scriptplan-0.9.0.dist-info/entry_points.txt +2 -0
- scriptplan-0.9.0.dist-info/licenses/LICENSE +201 -0
- 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()
|