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