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,515 @@
|
|
|
1
|
+
"""MessageHandler module for managing application messages.
|
|
2
|
+
|
|
3
|
+
Contains Message, MessageHandlerInstance singleton, and MessageHandler mixin
|
|
4
|
+
for handling fatal errors, errors, warnings, info, and debug messages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional, List, Dict, Any, Union
|
|
12
|
+
|
|
13
|
+
from scriptplan.utils.logger import ANSIColor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TjRuntimeError(RuntimeError):
|
|
17
|
+
"""TaskJuggler runtime error exception."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TjException(Exception):
|
|
22
|
+
"""TaskJuggler exception for controlled abort."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SourceFileInfo:
|
|
27
|
+
"""Holds information about a source file location.
|
|
28
|
+
|
|
29
|
+
This is a simplified version - full implementation would be in TextParser module.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, file_name: str, line_no: int = 0, column_no: int = 0):
|
|
33
|
+
self._file_name = file_name
|
|
34
|
+
self._line_no = line_no
|
|
35
|
+
self._column_no = column_no
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def fileName(self) -> str:
|
|
39
|
+
return self._file_name
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def lineNo(self) -> int:
|
|
43
|
+
return self._line_no
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def columnNo(self) -> int:
|
|
47
|
+
return self._column_no
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"SourceFileInfo({self._file_name}:{self._line_no}:{self._column_no})"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MessageType(Enum):
|
|
54
|
+
"""Message severity types."""
|
|
55
|
+
FATAL = 'fatal'
|
|
56
|
+
ERROR = 'error'
|
|
57
|
+
CRITICAL = 'critical'
|
|
58
|
+
WARNING = 'warning'
|
|
59
|
+
INFO = 'info'
|
|
60
|
+
DEBUG = 'debug'
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Message:
|
|
64
|
+
"""Stores a single message with type, ID, content, and optional source info.
|
|
65
|
+
|
|
66
|
+
Supports five message types: fatal, error, warning, info, and debug.
|
|
67
|
+
Messages can include source file locations and specific line content for debugging.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
VALID_TYPES = [MessageType.FATAL, MessageType.ERROR, MessageType.WARNING,
|
|
71
|
+
MessageType.INFO, MessageType.DEBUG]
|
|
72
|
+
|
|
73
|
+
def __init__(self, msg_type: MessageType, msg_id: str, message: str,
|
|
74
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
75
|
+
line: Optional[str] = None,
|
|
76
|
+
data: Any = None,
|
|
77
|
+
scenario: Any = None):
|
|
78
|
+
"""Create a new Message object.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
msg_type: Message type (fatal, error, warning, info, debug).
|
|
82
|
+
msg_id: Unique identifier for the message source.
|
|
83
|
+
message: The actual message content.
|
|
84
|
+
source_file_info: Optional source file location reference.
|
|
85
|
+
line: Optional line content from the source file.
|
|
86
|
+
data: Optional context-sensitive data.
|
|
87
|
+
scenario: Optional Scenario where the message originated.
|
|
88
|
+
"""
|
|
89
|
+
if msg_type not in self.VALID_TYPES:
|
|
90
|
+
raise ValueError(f"Unknown message type: {msg_type}")
|
|
91
|
+
self._type = msg_type
|
|
92
|
+
|
|
93
|
+
self._id = msg_id
|
|
94
|
+
|
|
95
|
+
if message is not None and not isinstance(message, str):
|
|
96
|
+
raise TypeError(f"String object expected as message but got {type(message).__name__}")
|
|
97
|
+
self._message = message
|
|
98
|
+
|
|
99
|
+
if source_file_info is not None and not isinstance(source_file_info, SourceFileInfo):
|
|
100
|
+
raise TypeError(f"SourceFileInfo object expected but got {type(source_file_info).__name__}")
|
|
101
|
+
self._source_file_info = source_file_info
|
|
102
|
+
|
|
103
|
+
if line is not None and not isinstance(line, str):
|
|
104
|
+
raise TypeError(f"String object expected as line but got {type(line).__name__}")
|
|
105
|
+
self._line = line
|
|
106
|
+
|
|
107
|
+
self._data = data
|
|
108
|
+
self._scenario = scenario
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def type(self) -> MessageType:
|
|
112
|
+
return self._type
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def id(self) -> str:
|
|
116
|
+
return self._id
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def message(self) -> str:
|
|
120
|
+
return self._message
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def sourceFileInfo(self) -> Optional[SourceFileInfo]:
|
|
124
|
+
return self._source_file_info
|
|
125
|
+
|
|
126
|
+
@sourceFileInfo.setter
|
|
127
|
+
def sourceFileInfo(self, value: Optional[SourceFileInfo]):
|
|
128
|
+
self._source_file_info = value
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def line(self) -> Optional[str]:
|
|
132
|
+
return self._line
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def data(self) -> Any:
|
|
136
|
+
return self._data
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def scenario(self) -> Any:
|
|
140
|
+
return self._scenario
|
|
141
|
+
|
|
142
|
+
def __str__(self) -> str:
|
|
143
|
+
"""Return formatted string with ANSI colors for console output."""
|
|
144
|
+
result = ""
|
|
145
|
+
|
|
146
|
+
if self._source_file_info:
|
|
147
|
+
result += f"{self._source_file_info.fileName}:{self._source_file_info.lineNo}: "
|
|
148
|
+
|
|
149
|
+
if self._scenario and hasattr(self._scenario, 'id'):
|
|
150
|
+
tag = f"{self._type.value.capitalize()} in scenario {self._scenario.id}: "
|
|
151
|
+
else:
|
|
152
|
+
tag = f"{self._type.value.capitalize()}: "
|
|
153
|
+
|
|
154
|
+
colors = {
|
|
155
|
+
MessageType.FATAL: ANSIColor.red,
|
|
156
|
+
MessageType.ERROR: ANSIColor.red,
|
|
157
|
+
MessageType.WARNING: ANSIColor.yellow,
|
|
158
|
+
MessageType.INFO: ANSIColor.blue,
|
|
159
|
+
MessageType.DEBUG: ANSIColor.green,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
color_func = colors.get(self._type, lambda x: x)
|
|
163
|
+
result += color_func(tag + (self._message or ""))
|
|
164
|
+
|
|
165
|
+
if self._line:
|
|
166
|
+
result += "\n" + self._line
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def to_log(self) -> str:
|
|
171
|
+
"""Return plain text string for log file output."""
|
|
172
|
+
result = ""
|
|
173
|
+
|
|
174
|
+
if self._source_file_info:
|
|
175
|
+
result += f"{self._source_file_info.fileName}:{self._source_file_info.lineNo}: "
|
|
176
|
+
|
|
177
|
+
if self._scenario and hasattr(self._scenario, 'id'):
|
|
178
|
+
result += f"Scenario {self._scenario.id}: "
|
|
179
|
+
|
|
180
|
+
result += self._message or ""
|
|
181
|
+
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class MessageHandlerInstance:
|
|
186
|
+
"""Singleton class for managing messages and logging.
|
|
187
|
+
|
|
188
|
+
Manages message storage and output, tracks error counts, and controls
|
|
189
|
+
output levels for both console and log files.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
_instance = None
|
|
193
|
+
_lock = threading.Lock()
|
|
194
|
+
|
|
195
|
+
LOG_LEVELS = {
|
|
196
|
+
'none': 0,
|
|
197
|
+
MessageType.FATAL: 1,
|
|
198
|
+
MessageType.ERROR: 2,
|
|
199
|
+
MessageType.CRITICAL: 2,
|
|
200
|
+
MessageType.WARNING: 3,
|
|
201
|
+
MessageType.INFO: 4,
|
|
202
|
+
MessageType.DEBUG: 5,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def __new__(cls):
|
|
206
|
+
if cls._instance is None:
|
|
207
|
+
with cls._lock:
|
|
208
|
+
if cls._instance is None:
|
|
209
|
+
cls._instance = super().__new__(cls)
|
|
210
|
+
cls._instance._initialized = False
|
|
211
|
+
return cls._instance
|
|
212
|
+
|
|
213
|
+
def __init__(self):
|
|
214
|
+
if self._initialized:
|
|
215
|
+
return
|
|
216
|
+
self._initialized = True
|
|
217
|
+
self.reset()
|
|
218
|
+
|
|
219
|
+
def reset(self):
|
|
220
|
+
"""Reset all handler state to defaults."""
|
|
221
|
+
self._output_level = 4
|
|
222
|
+
self._log_level = 3
|
|
223
|
+
self._log_file: Optional[str] = None
|
|
224
|
+
self._hide_scenario = True
|
|
225
|
+
self._app_name = 'unknown'
|
|
226
|
+
self._abort_on_warning = False
|
|
227
|
+
self._baseline_sfi: Dict[int, SourceFileInfo] = {}
|
|
228
|
+
self._trap_setup: Dict[int, bool] = {}
|
|
229
|
+
|
|
230
|
+
self.clear()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def messages(self) -> List[Message]:
|
|
234
|
+
return self._messages
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def errors(self) -> int:
|
|
238
|
+
return self._errors
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def logFile(self) -> Optional[str]:
|
|
242
|
+
return self._log_file
|
|
243
|
+
|
|
244
|
+
@logFile.setter
|
|
245
|
+
def logFile(self, value: Optional[str]):
|
|
246
|
+
self._log_file = value
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def appName(self) -> str:
|
|
250
|
+
return self._app_name
|
|
251
|
+
|
|
252
|
+
@appName.setter
|
|
253
|
+
def appName(self, value: str):
|
|
254
|
+
self._app_name = value
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def abortOnWarning(self) -> bool:
|
|
258
|
+
return self._abort_on_warning
|
|
259
|
+
|
|
260
|
+
@abortOnWarning.setter
|
|
261
|
+
def abortOnWarning(self, value: bool):
|
|
262
|
+
self._abort_on_warning = value
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def baselineSFI(self) -> Optional[SourceFileInfo]:
|
|
266
|
+
return self._baseline_sfi.get(threading.current_thread().ident)
|
|
267
|
+
|
|
268
|
+
@baselineSFI.setter
|
|
269
|
+
def baselineSFI(self, value: Optional[SourceFileInfo]):
|
|
270
|
+
self._baseline_sfi[threading.current_thread().ident] = value
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def trapSetup(self) -> bool:
|
|
274
|
+
return self._trap_setup.get(threading.current_thread().ident, False)
|
|
275
|
+
|
|
276
|
+
@trapSetup.setter
|
|
277
|
+
def trapSetup(self, value: bool):
|
|
278
|
+
self._trap_setup[threading.current_thread().ident] = value
|
|
279
|
+
|
|
280
|
+
def clear(self):
|
|
281
|
+
"""Clear all stored messages and reset error count."""
|
|
282
|
+
self._errors = 0
|
|
283
|
+
self._messages: List[Message] = []
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def outputLevel(self) -> int:
|
|
287
|
+
return self._output_level
|
|
288
|
+
|
|
289
|
+
@outputLevel.setter
|
|
290
|
+
def outputLevel(self, level: Union[int, str, MessageType]):
|
|
291
|
+
self._output_level = self._check_level(level)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def logLevel(self) -> int:
|
|
295
|
+
return self._log_level
|
|
296
|
+
|
|
297
|
+
@logLevel.setter
|
|
298
|
+
def logLevel(self, level: Union[int, str, MessageType]):
|
|
299
|
+
self._log_level = self._check_level(level)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def hideScenario(self) -> bool:
|
|
303
|
+
return self._hide_scenario
|
|
304
|
+
|
|
305
|
+
@hideScenario.setter
|
|
306
|
+
def hideScenario(self, value: bool):
|
|
307
|
+
self._hide_scenario = value
|
|
308
|
+
|
|
309
|
+
def _check_level(self, level: Union[int, str, MessageType]) -> int:
|
|
310
|
+
"""Validate and convert log level to integer."""
|
|
311
|
+
if isinstance(level, int):
|
|
312
|
+
if level < 0 or level > 5:
|
|
313
|
+
raise ValueError(f"Unsupported level {level}")
|
|
314
|
+
return level
|
|
315
|
+
|
|
316
|
+
if isinstance(level, MessageType):
|
|
317
|
+
return self.LOG_LEVELS.get(level, 0)
|
|
318
|
+
|
|
319
|
+
if isinstance(level, str):
|
|
320
|
+
level_lower = level.lower()
|
|
321
|
+
for key, val in self.LOG_LEVELS.items():
|
|
322
|
+
if isinstance(key, str) and key == level_lower:
|
|
323
|
+
return val
|
|
324
|
+
elif isinstance(key, MessageType) and key.value == level_lower:
|
|
325
|
+
return val
|
|
326
|
+
|
|
327
|
+
raise ValueError(f"Unsupported level {level}")
|
|
328
|
+
|
|
329
|
+
def _log(self, msg_type: MessageType, message: str):
|
|
330
|
+
"""Write message to log file if configured."""
|
|
331
|
+
if not self._log_file:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
335
|
+
import os
|
|
336
|
+
pid = os.getpid()
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
with open(self._log_file, 'a') as f:
|
|
340
|
+
f.write(f"{timestamp} {msg_type.value} {self._app_name}[{pid}]: {message}\n")
|
|
341
|
+
except Exception as e:
|
|
342
|
+
print(f"Cannot write to log file {self._log_file}: {e}", file=sys.stderr)
|
|
343
|
+
|
|
344
|
+
def _add_message(self, msg_type: MessageType, msg_id: str, message: str,
|
|
345
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
346
|
+
line: Optional[str] = None,
|
|
347
|
+
data: Any = None,
|
|
348
|
+
scenario: Any = None):
|
|
349
|
+
"""Add a message and handle based on type."""
|
|
350
|
+
# Adjust source file info based on baseline
|
|
351
|
+
baseline_sfi = self.baselineSFI
|
|
352
|
+
if source_file_info and baseline_sfi:
|
|
353
|
+
source_file_info = SourceFileInfo(
|
|
354
|
+
baseline_sfi.fileName,
|
|
355
|
+
source_file_info.lineNo + baseline_sfi.lineNo - 1,
|
|
356
|
+
source_file_info.columnNo
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Create message - convert critical to error for display
|
|
360
|
+
display_type = MessageType.ERROR if msg_type == MessageType.CRITICAL else msg_type
|
|
361
|
+
msg = Message(
|
|
362
|
+
display_type, msg_id, message, source_file_info, line, data,
|
|
363
|
+
None if self._hide_scenario else scenario
|
|
364
|
+
)
|
|
365
|
+
self._messages.append(msg)
|
|
366
|
+
|
|
367
|
+
# Log if level is appropriate
|
|
368
|
+
if self._log_level >= self.LOG_LEVELS.get(msg_type, 0):
|
|
369
|
+
self._log(msg_type, msg.to_log())
|
|
370
|
+
|
|
371
|
+
# Output to stderr if level is appropriate
|
|
372
|
+
if self._output_level >= self.LOG_LEVELS.get(msg_type, 0):
|
|
373
|
+
print(str(msg), file=sys.stderr)
|
|
374
|
+
|
|
375
|
+
# Handle message type-specific actions
|
|
376
|
+
if msg_type == MessageType.WARNING:
|
|
377
|
+
if self._abort_on_warning:
|
|
378
|
+
raise TjException("")
|
|
379
|
+
elif msg_type == MessageType.CRITICAL:
|
|
380
|
+
self._errors += 1
|
|
381
|
+
elif msg_type == MessageType.ERROR:
|
|
382
|
+
self._errors += 1
|
|
383
|
+
if self.trapSetup:
|
|
384
|
+
raise TjRuntimeError()
|
|
385
|
+
else:
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
elif msg_type == MessageType.FATAL:
|
|
388
|
+
raise RuntimeError(message)
|
|
389
|
+
|
|
390
|
+
def fatal(self, msg_id: str, message: str,
|
|
391
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
392
|
+
line: Optional[str] = None,
|
|
393
|
+
data: Any = None,
|
|
394
|
+
scenario: Any = None):
|
|
395
|
+
"""Log a fatal error and raise RuntimeError."""
|
|
396
|
+
self._add_message(MessageType.FATAL, msg_id, message,
|
|
397
|
+
source_file_info, line, data, scenario)
|
|
398
|
+
|
|
399
|
+
def error(self, msg_id: str, message: str,
|
|
400
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
401
|
+
line: Optional[str] = None,
|
|
402
|
+
data: Any = None,
|
|
403
|
+
scenario: Any = None):
|
|
404
|
+
"""Log an error. Will exit or raise TjRuntimeError based on trapSetup."""
|
|
405
|
+
self._add_message(MessageType.ERROR, msg_id, message,
|
|
406
|
+
source_file_info, line, data, scenario)
|
|
407
|
+
|
|
408
|
+
def critical(self, msg_id: str, message: str,
|
|
409
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
410
|
+
line: Optional[str] = None,
|
|
411
|
+
data: Any = None,
|
|
412
|
+
scenario: Any = None):
|
|
413
|
+
"""Log a critical error. Increments error count but does not exit."""
|
|
414
|
+
self._add_message(MessageType.CRITICAL, msg_id, message,
|
|
415
|
+
source_file_info, line, data, scenario)
|
|
416
|
+
|
|
417
|
+
def warning(self, msg_id: str, message: str,
|
|
418
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
419
|
+
line: Optional[str] = None,
|
|
420
|
+
data: Any = None,
|
|
421
|
+
scenario: Any = None):
|
|
422
|
+
"""Log a warning. May raise TjException if abortOnWarning is set."""
|
|
423
|
+
self._add_message(MessageType.WARNING, msg_id, message,
|
|
424
|
+
source_file_info, line, data, scenario)
|
|
425
|
+
|
|
426
|
+
def info(self, msg_id: str, message: str,
|
|
427
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
428
|
+
line: Optional[str] = None,
|
|
429
|
+
data: Any = None,
|
|
430
|
+
scenario: Any = None):
|
|
431
|
+
"""Log an info message."""
|
|
432
|
+
self._add_message(MessageType.INFO, msg_id, message,
|
|
433
|
+
source_file_info, line, data, scenario)
|
|
434
|
+
|
|
435
|
+
def debug(self, msg_id: str, message: str,
|
|
436
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
437
|
+
line: Optional[str] = None,
|
|
438
|
+
data: Any = None,
|
|
439
|
+
scenario: Any = None):
|
|
440
|
+
"""Log a debug message."""
|
|
441
|
+
self._add_message(MessageType.DEBUG, msg_id, message,
|
|
442
|
+
source_file_info, line, data, scenario)
|
|
443
|
+
|
|
444
|
+
def __str__(self) -> str:
|
|
445
|
+
"""Return all messages as a single string."""
|
|
446
|
+
return ''.join(str(msg) for msg in self._messages)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Singleton accessor
|
|
450
|
+
def get_message_handler_instance() -> MessageHandlerInstance:
|
|
451
|
+
"""Return the MessageHandlerInstance singleton."""
|
|
452
|
+
return MessageHandlerInstance()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class MessageHandler:
|
|
456
|
+
"""Mixin class providing message handling methods.
|
|
457
|
+
|
|
458
|
+
Classes that inherit from MessageHandler can use fatal, error, critical,
|
|
459
|
+
warning, info, and debug methods to send messages through the global
|
|
460
|
+
MessageHandlerInstance singleton.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def fatal(self, msg_id: str, message: str,
|
|
464
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
465
|
+
line: Optional[str] = None,
|
|
466
|
+
data: Any = None,
|
|
467
|
+
scenario: Any = None):
|
|
468
|
+
"""Log a fatal error and raise RuntimeError."""
|
|
469
|
+
MessageHandlerInstance().fatal(msg_id, message, source_file_info,
|
|
470
|
+
line, data, scenario)
|
|
471
|
+
|
|
472
|
+
def error(self, msg_id: str, message: str,
|
|
473
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
474
|
+
line: Optional[str] = None,
|
|
475
|
+
data: Any = None,
|
|
476
|
+
scenario: Any = None):
|
|
477
|
+
"""Log an error."""
|
|
478
|
+
MessageHandlerInstance().error(msg_id, message, source_file_info,
|
|
479
|
+
line, data, scenario)
|
|
480
|
+
|
|
481
|
+
def critical(self, msg_id: str, message: str,
|
|
482
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
483
|
+
line: Optional[str] = None,
|
|
484
|
+
data: Any = None,
|
|
485
|
+
scenario: Any = None):
|
|
486
|
+
"""Log a critical error."""
|
|
487
|
+
MessageHandlerInstance().critical(msg_id, message, source_file_info,
|
|
488
|
+
line, data, scenario)
|
|
489
|
+
|
|
490
|
+
def warning(self, msg_id: str, message: str,
|
|
491
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
492
|
+
line: Optional[str] = None,
|
|
493
|
+
data: Any = None,
|
|
494
|
+
scenario: Any = None):
|
|
495
|
+
"""Log a warning."""
|
|
496
|
+
MessageHandlerInstance().warning(msg_id, message, source_file_info,
|
|
497
|
+
line, data, scenario)
|
|
498
|
+
|
|
499
|
+
def info(self, msg_id: str, message: str,
|
|
500
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
501
|
+
line: Optional[str] = None,
|
|
502
|
+
data: Any = None,
|
|
503
|
+
scenario: Any = None):
|
|
504
|
+
"""Log an info message."""
|
|
505
|
+
MessageHandlerInstance().info(msg_id, message, source_file_info,
|
|
506
|
+
line, data, scenario)
|
|
507
|
+
|
|
508
|
+
def debug(self, msg_id: str, message: str,
|
|
509
|
+
source_file_info: Optional[SourceFileInfo] = None,
|
|
510
|
+
line: Optional[str] = None,
|
|
511
|
+
data: Any = None,
|
|
512
|
+
scenario: Any = None):
|
|
513
|
+
"""Log a debug message."""
|
|
514
|
+
MessageHandlerInstance().debug(msg_id, message, source_file_info,
|
|
515
|
+
line, data, scenario)
|
scriptplan/utils/time.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
import time
|
|
3
|
+
import math
|
|
4
|
+
import calendar
|
|
5
|
+
|
|
6
|
+
class TjTime:
|
|
7
|
+
MON_MAX = [ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ]
|
|
8
|
+
_tz = 'UTC'
|
|
9
|
+
|
|
10
|
+
def __init__(self, t=None):
|
|
11
|
+
if t is None:
|
|
12
|
+
self.time = datetime.now(timezone.utc)
|
|
13
|
+
elif isinstance(t, datetime):
|
|
14
|
+
if t.tzinfo is None:
|
|
15
|
+
# Assume local? Or UTC? TaskJuggler defaults to UTC mostly or system
|
|
16
|
+
# Ruby Time.new creates local time.
|
|
17
|
+
# If we follow strict Ruby behavior, assumes local if no TZ.
|
|
18
|
+
# But here we try to keep things UTC aware internally.
|
|
19
|
+
self.time = t.replace(tzinfo=timezone.utc) # Simplified assumption: inputs are UTC if naive
|
|
20
|
+
else:
|
|
21
|
+
self.time = t
|
|
22
|
+
elif isinstance(t, TjTime):
|
|
23
|
+
self.time = t.time
|
|
24
|
+
elif isinstance(t, str):
|
|
25
|
+
self.parse(t)
|
|
26
|
+
elif isinstance(t, (list, tuple)):
|
|
27
|
+
# year, month, day, hour, min, sec, usec
|
|
28
|
+
# Ruby Time.mktime interpreted in local time
|
|
29
|
+
# For now assuming UTC for simplicity or explicit timezone handling needed
|
|
30
|
+
dt = datetime(*t[:6])
|
|
31
|
+
self.time = dt.replace(tzinfo=timezone.utc)
|
|
32
|
+
elif isinstance(t, (int, float)):
|
|
33
|
+
self.time = datetime.fromtimestamp(t, tz=timezone.utc)
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError(f"Unknown type for TjTime init: {type(t)}")
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def checkTimeZone(zone):
|
|
39
|
+
if zone == 'UTC': return True
|
|
40
|
+
if '/' not in zone: return False
|
|
41
|
+
# Basic validation not fully implemented against OS db
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def setTimeZone(cls, zone):
|
|
46
|
+
if not cls.checkTimeZone(zone):
|
|
47
|
+
raise ValueError(f"Illegal time zone {zone}")
|
|
48
|
+
old = cls._tz
|
|
49
|
+
cls._tz = zone
|
|
50
|
+
return old
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def timeZone(cls):
|
|
54
|
+
return cls._tz
|
|
55
|
+
|
|
56
|
+
def align(self, clock):
|
|
57
|
+
# clock is seconds
|
|
58
|
+
ts = self.time.timestamp()
|
|
59
|
+
aligned_ts = (int(ts) // clock) * clock
|
|
60
|
+
return TjTime(aligned_ts)
|
|
61
|
+
|
|
62
|
+
def utc(self):
|
|
63
|
+
return TjTime(self.time.astimezone(timezone.utc))
|
|
64
|
+
|
|
65
|
+
def secondsOfDay(self):
|
|
66
|
+
# Assuming local time relative to set timezone?
|
|
67
|
+
# Simplified: just return UTC seconds of day for now unless we implement full TZ handling
|
|
68
|
+
return self.time.hour * 3600 + self.time.minute * 60 + self.time.second
|
|
69
|
+
|
|
70
|
+
def __add__(self, secs):
|
|
71
|
+
return TjTime(self.time + timedelta(seconds=secs))
|
|
72
|
+
|
|
73
|
+
def __sub__(self, arg):
|
|
74
|
+
if isinstance(arg, TjTime):
|
|
75
|
+
return (self.time - arg.time).total_seconds()
|
|
76
|
+
else:
|
|
77
|
+
return TjTime(self.time - timedelta(seconds=arg))
|
|
78
|
+
|
|
79
|
+
def __lt__(self, other): return self.time < other.time
|
|
80
|
+
def __le__(self, other): return self.time <= other.time
|
|
81
|
+
def __gt__(self, other): return self.time > other.time
|
|
82
|
+
def __ge__(self, other): return self.time >= other.time
|
|
83
|
+
def __eq__(self, other): return self.time == other.time
|
|
84
|
+
|
|
85
|
+
def upto(self, endDate, step=1):
|
|
86
|
+
t = self
|
|
87
|
+
while t < endDate:
|
|
88
|
+
yield t
|
|
89
|
+
t += step
|
|
90
|
+
|
|
91
|
+
def beginOfHour(self):
|
|
92
|
+
return TjTime(self.time.replace(minute=0, second=0, microsecond=0))
|
|
93
|
+
|
|
94
|
+
def midnight(self):
|
|
95
|
+
return TjTime(self.time.replace(hour=0, minute=0, second=0, microsecond=0))
|
|
96
|
+
|
|
97
|
+
def beginOfWeek(self, startMonday):
|
|
98
|
+
# startMonday bool
|
|
99
|
+
dt = self.time
|
|
100
|
+
weekday = dt.weekday() # Mon=0, Sun=6
|
|
101
|
+
# If startMonday=True, we want Monday. weekday is already 0-based from Monday.
|
|
102
|
+
# If startMonday=False (Sunday), we want Sunday.
|
|
103
|
+
|
|
104
|
+
if startMonday:
|
|
105
|
+
days_to_subtract = weekday
|
|
106
|
+
else:
|
|
107
|
+
# If today is Sunday (6), subtract 0. If Mon (0), subtract 1.
|
|
108
|
+
days_to_subtract = (weekday + 1) % 7
|
|
109
|
+
|
|
110
|
+
start_of_week = dt - timedelta(days=days_to_subtract)
|
|
111
|
+
return TjTime(start_of_week).midnight()
|
|
112
|
+
|
|
113
|
+
def beginOfMonth(self):
|
|
114
|
+
return TjTime(self.time.replace(day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
115
|
+
|
|
116
|
+
def beginOfQuarter(self):
|
|
117
|
+
month = self.time.month
|
|
118
|
+
quarter_month = ((month - 1) // 3) * 3 + 1
|
|
119
|
+
return TjTime(self.time.replace(month=quarter_month, day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
120
|
+
|
|
121
|
+
def beginOfYear(self):
|
|
122
|
+
return TjTime(self.time.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
123
|
+
|
|
124
|
+
def hoursLater(self, hours):
|
|
125
|
+
return self + (hours * 3600)
|
|
126
|
+
|
|
127
|
+
def sameTimeNextDay(self):
|
|
128
|
+
return self + (24 * 3600) # Simple approx, ignores DST shifts if just adding seconds?
|
|
129
|
+
# Better: add timedelta(days=1) which handles calendar date change
|
|
130
|
+
# return TjTime(self.time + timedelta(days=1))
|
|
131
|
+
|
|
132
|
+
def sameTimeNextWeek(self):
|
|
133
|
+
return TjTime(self.time + timedelta(weeks=1))
|
|
134
|
+
|
|
135
|
+
def sameTimeNextMonth(self):
|
|
136
|
+
# Python doesn't have direct month add.
|
|
137
|
+
# Simple logic:
|
|
138
|
+
year = self.time.year
|
|
139
|
+
month = self.time.month + 1
|
|
140
|
+
if month > 12:
|
|
141
|
+
month = 1
|
|
142
|
+
year += 1
|
|
143
|
+
|
|
144
|
+
day = self.time.day
|
|
145
|
+
# Clamp day
|
|
146
|
+
_, max_days = calendar.monthrange(year, month)
|
|
147
|
+
day = min(day, max_days)
|
|
148
|
+
|
|
149
|
+
return TjTime(self.time.replace(year=year, month=month, day=day))
|
|
150
|
+
|
|
151
|
+
def sameTimeNextYear(self):
|
|
152
|
+
year = self.time.year + 1
|
|
153
|
+
day = self.time.day
|
|
154
|
+
# Handle leap year feb 29
|
|
155
|
+
if self.time.month == 2 and self.time.day == 29:
|
|
156
|
+
if not calendar.isleap(year):
|
|
157
|
+
day = 28
|
|
158
|
+
return TjTime(self.time.replace(year=year, day=day))
|
|
159
|
+
|
|
160
|
+
def strftime(self, fmt):
|
|
161
|
+
return self.time.strftime(fmt)
|
|
162
|
+
|
|
163
|
+
def to_s(self, fmt=None, tz=None):
|
|
164
|
+
if not fmt:
|
|
165
|
+
fmt = '%Y-%m-%d-%H:%M'
|
|
166
|
+
return self.time.strftime(fmt)
|
|
167
|
+
|
|
168
|
+
def parse(self, t):
|
|
169
|
+
# format YYYY-MM-DD-HH:MM:SS-ZZZZ?
|
|
170
|
+
# Ruby impl splits by '-'
|
|
171
|
+
parts = t.split('-')
|
|
172
|
+
# Handle various parts length
|
|
173
|
+
# Expected: Year, Month, Day, [Time, [Zone]]
|
|
174
|
+
year = int(parts[0])
|
|
175
|
+
month = int(parts[1])
|
|
176
|
+
day = int(parts[2])
|
|
177
|
+
hour = 0
|
|
178
|
+
minute = 0
|
|
179
|
+
second = 0
|
|
180
|
+
|
|
181
|
+
if len(parts) > 3:
|
|
182
|
+
time_part = parts[3]
|
|
183
|
+
if ':' in time_part:
|
|
184
|
+
h, m, s = time_part.split(':')
|
|
185
|
+
hour = int(h)
|
|
186
|
+
minute = int(m)
|
|
187
|
+
second = int(s) if s else 0
|
|
188
|
+
|
|
189
|
+
# Ignore zone for now or handle if present
|
|
190
|
+
self.time = datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc)
|
|
191
|
+
|
|
192
|
+
class TimeInterval:
|
|
193
|
+
def __init__(self, start, end):
|
|
194
|
+
self.start = start
|
|
195
|
+
self.end = end
|