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,354 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Limits implementation for resource allocation constraints.
|
|
3
|
+
|
|
4
|
+
Implements the limit mechanism that can restrict resource allocation within
|
|
5
|
+
certain time periods (daily, weekly, etc.). Supports both upper and lower limits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from scriptplan.scheduler.scoreboard import Scoreboard
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Limit:
|
|
12
|
+
"""
|
|
13
|
+
A single limit constraint that tracks usage within time periods.
|
|
14
|
+
|
|
15
|
+
Limits can be:
|
|
16
|
+
- dailymax/dailymin: Limit per day
|
|
17
|
+
- weeklymax/weeklymin: Limit per week
|
|
18
|
+
- monthlymax/monthlymin: Limit per month
|
|
19
|
+
- maximum/minimum: Limit for the entire interval
|
|
20
|
+
|
|
21
|
+
Limits can optionally be restricted to specific resources.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, name, interval_start, interval_end, period, value, upper, resource=None, slot_duration=3600):
|
|
25
|
+
"""
|
|
26
|
+
Create a new Limit.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
name: Limit type name ('dailymax', 'weeklymax', etc.)
|
|
30
|
+
interval_start: Start of the interval (datetime)
|
|
31
|
+
interval_end: End of the interval (datetime)
|
|
32
|
+
period: Duration of each period in seconds (86400 for daily)
|
|
33
|
+
value: The limit value in slots
|
|
34
|
+
upper: True for upper limit, False for lower limit
|
|
35
|
+
resource: Optional resource this limit applies to
|
|
36
|
+
slot_duration: Duration of each scheduling slot in seconds (default 1 hour)
|
|
37
|
+
"""
|
|
38
|
+
self.name = name
|
|
39
|
+
self.interval_start = interval_start
|
|
40
|
+
self.interval_end = interval_end
|
|
41
|
+
self.period = period
|
|
42
|
+
self.value = value
|
|
43
|
+
self.upper = upper
|
|
44
|
+
self.resource = resource
|
|
45
|
+
self.slot_duration = slot_duration
|
|
46
|
+
|
|
47
|
+
self._dirty = True
|
|
48
|
+
self._scoreboard = None
|
|
49
|
+
self.reset()
|
|
50
|
+
|
|
51
|
+
def copy(self):
|
|
52
|
+
"""Return a deep copy of this limit."""
|
|
53
|
+
limit = Limit(
|
|
54
|
+
self.name,
|
|
55
|
+
self.interval_start,
|
|
56
|
+
self.interval_end,
|
|
57
|
+
self.period,
|
|
58
|
+
self.value,
|
|
59
|
+
self.upper,
|
|
60
|
+
self.resource,
|
|
61
|
+
self.slot_duration
|
|
62
|
+
)
|
|
63
|
+
return limit
|
|
64
|
+
|
|
65
|
+
def reset(self, index=None):
|
|
66
|
+
"""
|
|
67
|
+
Reset counters for all periods or a specific period.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
index: If provided, reset only the counter for this scoreboard index
|
|
71
|
+
"""
|
|
72
|
+
if not self._dirty:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if index is None:
|
|
76
|
+
# Calculate number of periods in the interval
|
|
77
|
+
total_seconds = (self.interval_end - self.interval_start).total_seconds()
|
|
78
|
+
num_periods = max(1, int(total_seconds / self.period) + 1)
|
|
79
|
+
self._scoreboard = [0] * num_periods
|
|
80
|
+
else:
|
|
81
|
+
# Reset only the specific period
|
|
82
|
+
if self._contains(index):
|
|
83
|
+
sb_idx = self._idx_to_sb_idx(index)
|
|
84
|
+
if 0 <= sb_idx < len(self._scoreboard):
|
|
85
|
+
self._scoreboard[sb_idx] = 0
|
|
86
|
+
|
|
87
|
+
self._dirty = False
|
|
88
|
+
|
|
89
|
+
def _contains(self, index):
|
|
90
|
+
"""Check if a scoreboard index falls within this limit's interval."""
|
|
91
|
+
# Convert index to datetime for comparison
|
|
92
|
+
# index is the project scoreboard index
|
|
93
|
+
return True # We'll check bounds in _idx_to_sb_idx
|
|
94
|
+
|
|
95
|
+
def _idx_to_sb_idx(self, index):
|
|
96
|
+
"""
|
|
97
|
+
Convert project scoreboard index to limit scoreboard index.
|
|
98
|
+
|
|
99
|
+
The limit scoreboard has larger slots (e.g., one per day/week) while
|
|
100
|
+
the project scoreboard has hourly slots.
|
|
101
|
+
|
|
102
|
+
For weekly limits, uses ISO week boundaries (Monday-Sunday) rather than
|
|
103
|
+
arbitrary 7-day chunks from project start. This ensures weeklymax resets
|
|
104
|
+
properly on Monday regardless of when the project started.
|
|
105
|
+
|
|
106
|
+
For daily limits, uses calendar day boundaries.
|
|
107
|
+
"""
|
|
108
|
+
from datetime import timedelta
|
|
109
|
+
|
|
110
|
+
# Calculate the actual datetime for this slot
|
|
111
|
+
slot_datetime = self.interval_start + timedelta(seconds=index * self.slot_duration)
|
|
112
|
+
|
|
113
|
+
if self.period == 60 * 60 * 24 * 7: # Weekly
|
|
114
|
+
# Use ISO week number for proper Monday-Sunday week boundaries
|
|
115
|
+
# isocalendar() returns (year, week_number, weekday)
|
|
116
|
+
iso_year, iso_week, _ = slot_datetime.isocalendar()
|
|
117
|
+
start_year, start_week, _ = self.interval_start.isocalendar()
|
|
118
|
+
|
|
119
|
+
# Calculate week offset from project start
|
|
120
|
+
# Account for year boundaries
|
|
121
|
+
if iso_year == start_year:
|
|
122
|
+
return iso_week - start_week
|
|
123
|
+
else:
|
|
124
|
+
# Handle year boundary - weeks from start year + weeks in new year
|
|
125
|
+
# ISO week 1 of new year follows week 52 or 53 of previous year
|
|
126
|
+
weeks_in_start_year = self.interval_start.replace(month=12, day=28).isocalendar()[1]
|
|
127
|
+
return (weeks_in_start_year - start_week + 1) + (iso_week - 1) + \
|
|
128
|
+
52 * (iso_year - start_year - 1)
|
|
129
|
+
|
|
130
|
+
elif self.period == 60 * 60 * 24: # Daily
|
|
131
|
+
# Use calendar day boundaries
|
|
132
|
+
start_date = self.interval_start.date()
|
|
133
|
+
slot_date = slot_datetime.date()
|
|
134
|
+
return (slot_date - start_date).days
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
# For other periods, use simple division from project start
|
|
138
|
+
slot_seconds = index * self.slot_duration
|
|
139
|
+
return int(slot_seconds / self.period)
|
|
140
|
+
|
|
141
|
+
def inc(self, index, resource=None):
|
|
142
|
+
"""
|
|
143
|
+
Increment the counter if index matches the interval and resource.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
index: Project scoreboard index
|
|
147
|
+
resource: Resource being booked (for resource-specific limits)
|
|
148
|
+
"""
|
|
149
|
+
# Check resource match:
|
|
150
|
+
# If self.resource is None, always increment
|
|
151
|
+
# If self.resource is set, only increment if it matches
|
|
152
|
+
if self.resource is not None and self.resource != resource:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
sb_idx = self._idx_to_sb_idx(index)
|
|
156
|
+
if 0 <= sb_idx < len(self._scoreboard):
|
|
157
|
+
self._dirty = True
|
|
158
|
+
self._scoreboard[sb_idx] += 1
|
|
159
|
+
|
|
160
|
+
def dec(self, index, resource=None):
|
|
161
|
+
"""
|
|
162
|
+
Decrement the counter if index matches the interval and resource.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
index: Project scoreboard index
|
|
166
|
+
resource: Resource being unbooked (for resource-specific limits)
|
|
167
|
+
"""
|
|
168
|
+
if self.resource is not None and self.resource != resource:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
sb_idx = self._idx_to_sb_idx(index)
|
|
172
|
+
if 0 <= sb_idx < len(self._scoreboard):
|
|
173
|
+
self._dirty = True
|
|
174
|
+
self._scoreboard[sb_idx] -= 1
|
|
175
|
+
|
|
176
|
+
def ok(self, index, upper, resource=None):
|
|
177
|
+
"""
|
|
178
|
+
Check if the counter is within the limit.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
index: Project scoreboard index (or None to check all)
|
|
182
|
+
upper: True to check upper limits, False for lower limits
|
|
183
|
+
resource: Resource to check (for resource-specific limits)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if within limit, False if exceeded
|
|
187
|
+
"""
|
|
188
|
+
# If this limit's type (upper/lower) doesn't match what we're checking, return True
|
|
189
|
+
if self.upper != upper:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
# For resource-specific limits:
|
|
193
|
+
# - If self.resource is set and doesn't match the provided resource, return True
|
|
194
|
+
# - If self.resource is None, check regardless of resource (general limit)
|
|
195
|
+
if self.resource is not None and self.resource != resource:
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
if index is None:
|
|
199
|
+
# Check all periods
|
|
200
|
+
for count in self._scoreboard:
|
|
201
|
+
if self.upper:
|
|
202
|
+
if count >= self.value:
|
|
203
|
+
return False
|
|
204
|
+
else:
|
|
205
|
+
if count < self.value:
|
|
206
|
+
return False
|
|
207
|
+
return True
|
|
208
|
+
else:
|
|
209
|
+
sb_idx = self._idx_to_sb_idx(index)
|
|
210
|
+
if sb_idx < 0 or sb_idx >= len(self._scoreboard):
|
|
211
|
+
return True # Outside interval, OK
|
|
212
|
+
|
|
213
|
+
count = self._scoreboard[sb_idx]
|
|
214
|
+
if self.upper:
|
|
215
|
+
return count < self.value
|
|
216
|
+
else:
|
|
217
|
+
return count >= self.value
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Limits:
|
|
221
|
+
"""
|
|
222
|
+
A collection of Limit objects for a task or resource.
|
|
223
|
+
|
|
224
|
+
Supports setting multiple limits and checking/incrementing them all at once.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def __init__(self, limits=None):
|
|
228
|
+
"""
|
|
229
|
+
Create a new Limits collection.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
limits: Optional existing Limits to copy from
|
|
233
|
+
"""
|
|
234
|
+
self._limits = []
|
|
235
|
+
self.project = None
|
|
236
|
+
|
|
237
|
+
if limits is not None:
|
|
238
|
+
# Deep copy from existing
|
|
239
|
+
for limit in limits._limits:
|
|
240
|
+
self._limits.append(limit.copy())
|
|
241
|
+
self.project = limits.project
|
|
242
|
+
|
|
243
|
+
def copy(self):
|
|
244
|
+
"""Return a deep copy of this Limits collection."""
|
|
245
|
+
return Limits(self)
|
|
246
|
+
|
|
247
|
+
def setProject(self, project):
|
|
248
|
+
"""Set the project reference."""
|
|
249
|
+
if self._limits:
|
|
250
|
+
raise RuntimeError("Cannot change project after limits have been set!")
|
|
251
|
+
self.project = project
|
|
252
|
+
|
|
253
|
+
def reset(self):
|
|
254
|
+
"""Reset all limit counters."""
|
|
255
|
+
for limit in self._limits:
|
|
256
|
+
limit.reset()
|
|
257
|
+
|
|
258
|
+
def setLimit(self, name, value, interval=None, resource=None):
|
|
259
|
+
"""
|
|
260
|
+
Create or update a limit.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
name: Limit type ('dailymax', 'weeklymax', etc.)
|
|
264
|
+
value: Limit value in slots (e.g., 6 for 6 hours)
|
|
265
|
+
interval: Optional (start, end) tuple for the limit interval
|
|
266
|
+
resource: Optional resource this limit applies to
|
|
267
|
+
"""
|
|
268
|
+
if self.project is None:
|
|
269
|
+
raise RuntimeError("Project must be set before adding limits")
|
|
270
|
+
|
|
271
|
+
# Use project interval if not specified
|
|
272
|
+
if interval is None:
|
|
273
|
+
interval_start = self.project['start']
|
|
274
|
+
interval_end = self.project['end']
|
|
275
|
+
else:
|
|
276
|
+
interval_start, interval_end = interval
|
|
277
|
+
|
|
278
|
+
# Determine period and slot duration based on project settings
|
|
279
|
+
slot_duration = self.project.attributes.get('scheduleGranularity', 3600)
|
|
280
|
+
|
|
281
|
+
# Convert value from hours to slots
|
|
282
|
+
# e.g., 3.5h with 15-min (0.25h) slots = 14 slots
|
|
283
|
+
slot_duration_hours = slot_duration / 3600.0
|
|
284
|
+
value_in_slots = int(value / slot_duration_hours)
|
|
285
|
+
|
|
286
|
+
if name == 'dailymax':
|
|
287
|
+
period = 60 * 60 * 24 # 1 day in seconds
|
|
288
|
+
upper = True
|
|
289
|
+
elif name == 'dailymin':
|
|
290
|
+
period = 60 * 60 * 24
|
|
291
|
+
upper = False
|
|
292
|
+
elif name == 'weeklymax':
|
|
293
|
+
period = 60 * 60 * 24 * 7 # 1 week in seconds
|
|
294
|
+
upper = True
|
|
295
|
+
elif name == 'weeklymin':
|
|
296
|
+
period = 60 * 60 * 24 * 7
|
|
297
|
+
upper = False
|
|
298
|
+
elif name == 'monthlymax':
|
|
299
|
+
period = 60 * 60 * 24 * 30 # ~1 month
|
|
300
|
+
upper = True
|
|
301
|
+
elif name == 'monthlymin':
|
|
302
|
+
period = 60 * 60 * 24 * 30
|
|
303
|
+
upper = False
|
|
304
|
+
elif name == 'maximum':
|
|
305
|
+
period = (interval_end - interval_start).total_seconds()
|
|
306
|
+
upper = True
|
|
307
|
+
elif name == 'minimum':
|
|
308
|
+
period = (interval_end - interval_start).total_seconds()
|
|
309
|
+
upper = False
|
|
310
|
+
else:
|
|
311
|
+
raise ValueError(f"Unknown limit type: {name}")
|
|
312
|
+
|
|
313
|
+
# Remove existing limit with same name + resource combination
|
|
314
|
+
self._limits = [l for l in self._limits
|
|
315
|
+
if not (l.name == name and l.resource == resource)]
|
|
316
|
+
|
|
317
|
+
# Add new limit (using value_in_slots which is calculated from hours)
|
|
318
|
+
self._limits.append(Limit(
|
|
319
|
+
name, interval_start, interval_end, period, value_in_slots, upper, resource, slot_duration
|
|
320
|
+
))
|
|
321
|
+
|
|
322
|
+
def inc(self, index, resource=None):
|
|
323
|
+
"""Increment all limit counters for the given index."""
|
|
324
|
+
for limit in self._limits:
|
|
325
|
+
limit.inc(index, resource)
|
|
326
|
+
|
|
327
|
+
def dec(self, index, resource=None):
|
|
328
|
+
"""Decrement all limit counters for the given index."""
|
|
329
|
+
for limit in self._limits:
|
|
330
|
+
limit.dec(index, resource)
|
|
331
|
+
|
|
332
|
+
def ok(self, index=None, upper=True, resource=None):
|
|
333
|
+
"""
|
|
334
|
+
Check if all limits are satisfied.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
index: Scoreboard index to check (or None for all)
|
|
338
|
+
upper: True to check upper limits, False for lower
|
|
339
|
+
resource: Resource to check for resource-specific limits
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
True if all limits are satisfied
|
|
343
|
+
"""
|
|
344
|
+
for limit in self._limits:
|
|
345
|
+
if not limit.ok(index, upper, resource):
|
|
346
|
+
return False
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
def __bool__(self):
|
|
350
|
+
"""Return True if there are any limits."""
|
|
351
|
+
return len(self._limits) > 0
|
|
352
|
+
|
|
353
|
+
def __len__(self):
|
|
354
|
+
return len(self._limits)
|