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,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)
@@ -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