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,1904 @@
1
+ """TJP Parser for TaskJuggler project files."""
2
+
3
+ from lark import Lark, Transformer, v_args, Token, Tree
4
+ from scriptplan.core.project import Project
5
+ from scriptplan.core.task import Task
6
+ from scriptplan.core.resource import Resource
7
+ from scriptplan.parser.macro_processor import preprocess_tjp
8
+ from datetime import datetime
9
+ import os
10
+
11
+
12
+ class TJPTransformer(Transformer):
13
+ """Transform the parse tree into a dictionary structure."""
14
+
15
+ def start(self, items):
16
+ return items[0] if items else {}
17
+
18
+ def statements(self, items):
19
+ result = {
20
+ 'project': None,
21
+ 'global_attributes': [],
22
+ 'property_declarations': [],
23
+ 'reports': [],
24
+ 'navigators': []
25
+ }
26
+ for item in items:
27
+ if isinstance(item, dict):
28
+ if item.get('type') == 'project':
29
+ result['project'] = item
30
+ elif item.get('type') in ['resource', 'task', 'account', 'shift']:
31
+ result['property_declarations'].append(item)
32
+ elif item.get('type') in ['taskreport', 'resourcereport', 'textreport']:
33
+ result['reports'].append(item)
34
+ elif item.get('type') == 'navigator':
35
+ result['navigators'].append(item)
36
+ elif isinstance(item, tuple):
37
+ result['global_attributes'].append(item)
38
+ return result
39
+
40
+ def statement(self, items):
41
+ return items[0] if items else None
42
+
43
+ # Project definition
44
+ def project(self, items):
45
+ # items[0] is always project_id
46
+ # items[1] might be project_name (if present) or project_timeframe
47
+ # We need to check the type to determine
48
+ p_id = self._get_value(items[0])
49
+
50
+ idx = 1
51
+ # Check if items[1] is a string (project_name) or a dict (timeframe)
52
+ if len(items) > idx and isinstance(items[idx], str):
53
+ p_name = items[idx]
54
+ idx += 1
55
+ else:
56
+ p_name = p_id # Use id as name if not specified
57
+
58
+ timeframe = items[idx] if len(items) > idx else {}
59
+ idx += 1
60
+ attrs = items[idx] if len(items) > idx else []
61
+
62
+ return {
63
+ 'type': 'project',
64
+ 'id': p_id,
65
+ 'name': p_name,
66
+ 'timeframe': timeframe,
67
+ 'attributes': attrs
68
+ }
69
+
70
+ def project_id(self, items):
71
+ return self._get_value(items[0])
72
+
73
+ def project_name(self, items):
74
+ return self._get_value(items[0])
75
+
76
+ def project_timeframe(self, items):
77
+ result = {'start': items[0]}
78
+ if len(items) > 1 and items[1]:
79
+ result['duration'] = items[1]
80
+ return result
81
+
82
+ def duration_spec(self, items):
83
+ return self._get_value(items[0]) if items else None
84
+
85
+ def project_attributes(self, items):
86
+ return list(items)
87
+
88
+ def project_attribute(self, items):
89
+ return items[0] if items else None
90
+
91
+ def project_scheduling(self, items):
92
+ mode = self._get_value(items[0]).lower()
93
+ # forward=True means ASAP, forward=False means ALAP
94
+ return ('scheduling', mode)
95
+
96
+ # Global attributes
97
+ def global_attribute(self, items):
98
+ return items[0] if items else None
99
+
100
+ def copyright(self, items):
101
+ return ('copyright', self._get_value(items[0]))
102
+
103
+ def rate(self, items):
104
+ return ('rate', float(self._get_value(items[0])))
105
+
106
+ def leaves_global(self, items):
107
+ return ('leaves', {
108
+ 'type': self._get_value(items[0]),
109
+ 'name': self._get_value(items[1]),
110
+ 'start': items[2],
111
+ 'end': items[3] if len(items) > 3 else None
112
+ })
113
+
114
+ def flags_global(self, items):
115
+ return ('flags', [self._get_value(i) for i in items])
116
+
117
+ def balance(self, items):
118
+ return ('balance', (self._get_value(items[0]), self._get_value(items[1])))
119
+
120
+ def vacation_global(self, items):
121
+ # vacation_global: "vacation" STRING? date ("-" date)?
122
+ # After transformation, items contains: optional string name, datetime(s) from date rule
123
+ name = None
124
+ start_date = None
125
+ end_date = None
126
+ for item in items:
127
+ if isinstance(item, datetime):
128
+ if start_date is None:
129
+ start_date = item
130
+ else:
131
+ end_date = item
132
+ elif isinstance(item, str):
133
+ name = item
134
+ return ('vacation', {'name': name, 'start': start_date, 'end': end_date or start_date})
135
+
136
+ # Project attribute handlers
137
+ def timezone(self, items):
138
+ return ('timezone', self._get_value(items[0]))
139
+
140
+ def timeformat(self, items):
141
+ return ('timeformat', self._get_value(items[0]))
142
+
143
+ def numberformat(self, items):
144
+ return ('numberformat', [self._get_value(i) for i in items])
145
+
146
+ def currencyformat(self, items):
147
+ return ('currencyformat', [self._get_value(i) for i in items])
148
+
149
+ def currency(self, items):
150
+ return ('currency', self._get_value(items[0]))
151
+
152
+ def now(self, items):
153
+ return ('now', items[0])
154
+
155
+ def dailyworkinghours(self, items):
156
+ return ('dailyworkinghours', float(self._get_value(items[0])))
157
+
158
+ def yearlyworkingdays(self, items):
159
+ return ('yearlyworkingdays', float(self._get_value(items[0])))
160
+
161
+ # Scenario
162
+ def scenario_def(self, items):
163
+ s_id = self._get_value(items[0])
164
+ s_name = self._get_value(items[1])
165
+ body = items[2] if len(items) > 2 else []
166
+ return ('scenario', {'id': s_id, 'name': s_name, 'children': body})
167
+
168
+ def scenario_body(self, items):
169
+ return list(items)
170
+
171
+ # Extend
172
+ def extend(self, items):
173
+ e_type = self._get_value(items[0])
174
+ attrs = items[1] if len(items) > 1 else []
175
+ return ('extend', {'type': e_type, 'attributes': attrs})
176
+
177
+ def extend_body(self, items):
178
+ return list(items)
179
+
180
+ def extend_attribute(self, items):
181
+ # Grammar: "text" ID STRING - "text" is literal, so only ID and STRING in items
182
+ return {
183
+ 'type': 'text',
184
+ 'name': self._get_value(items[0]),
185
+ 'label': self._get_value(items[1])
186
+ }
187
+
188
+ # Property declarations
189
+ def property_declaration(self, items):
190
+ return items[0] if items else None
191
+
192
+ # Resource
193
+ def resource(self, items):
194
+ r_id = self._get_value(items[0])
195
+ r_name = self._get_value(items[1])
196
+ body = items[2] if len(items) > 2 else []
197
+ return {
198
+ 'type': 'resource',
199
+ 'id': r_id,
200
+ 'name': r_name,
201
+ 'attributes': body
202
+ }
203
+
204
+ def resource_body(self, items):
205
+ return list(items)
206
+
207
+ def resource_attr(self, items):
208
+ return items[0] if items else None
209
+
210
+ def resource_email(self, items):
211
+ return ('email', self._get_value(items[0]))
212
+
213
+ def resource_rate(self, items):
214
+ return ('rate', float(self._get_value(items[0])))
215
+
216
+ def resource_efficiency(self, items):
217
+ return ('efficiency', float(self._get_value(items[0])))
218
+
219
+ def resource_timezone(self, items):
220
+ return ('timezone', self._get_value(items[0]))
221
+
222
+ def resource_managers(self, items):
223
+ return ('managers', [self._get_value(i) for i in items])
224
+
225
+ def resource_limits(self, items):
226
+ return ('limits', items[0] if items else [])
227
+
228
+ def limits_body(self, items):
229
+ """Parse limits body containing limit_attr items."""
230
+ return list(items) if items else []
231
+
232
+ def limit_attr(self, items):
233
+ """Parse a single limit attribute - pass through the dailymax/weeklymax result."""
234
+ return items[0] if items else None
235
+
236
+ def limit_dailymax(self, items):
237
+ """Parse dailymax limit."""
238
+ duration = items[0] if items else '0h'
239
+ resources = items[1] if len(items) > 1 else None
240
+ # Store value in hours - conversion to slots happens in Limits class
241
+ hours = self._parse_duration_to_hours(duration, round_to_slots=False) if isinstance(duration, str) else float(duration)
242
+ return {
243
+ 'type': 'dailymax',
244
+ 'value': hours,
245
+ 'resources': resources
246
+ }
247
+
248
+ def limit_weeklymax(self, items):
249
+ """Parse weeklymax limit."""
250
+ duration = items[0] if items else '0h'
251
+ resources = items[1] if len(items) > 1 else None
252
+ # Store value in hours - conversion to slots happens in Limits class
253
+ hours = self._parse_duration_to_hours(duration, round_to_slots=False) if isinstance(duration, str) else float(duration)
254
+ return {
255
+ 'type': 'weeklymax',
256
+ 'value': hours,
257
+ 'resources': resources
258
+ }
259
+
260
+ def limits_resources(self, items):
261
+ """Parse limits resources: { resources id1, id2, ... }."""
262
+ return [self._get_value(i) for i in items]
263
+
264
+ def _parse_duration_to_hours(self, duration_str, round_to_slots=False):
265
+ """Parse duration string to hours.
266
+
267
+ Args:
268
+ duration_str: Duration string like '6.4h', '2d', '1w'
269
+ round_to_slots: If True, round to integer hours (for limits)
270
+
271
+ Returns:
272
+ Duration in hours (float or int depending on round_to_slots)
273
+ """
274
+ import re
275
+ match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmy]?)', str(duration_str))
276
+ if match:
277
+ value = float(match.group(1))
278
+ unit = match.group(2) or 'h'
279
+ if unit == 'h':
280
+ hours = value
281
+ elif unit == 'd':
282
+ hours = value * 8 # 8 hours per day
283
+ elif unit == 'w':
284
+ hours = value * 40 # 40 hours per week
285
+ elif unit == 'm':
286
+ hours = value * 160 # ~160 hours per month
287
+ elif unit == 'y':
288
+ hours = value * 2000 # ~2000 hours per year
289
+ else:
290
+ hours = 0
291
+
292
+ # TaskJuggler rounds limit values to integer slots
293
+ if round_to_slots:
294
+ return round(hours)
295
+ return hours
296
+ return 0
297
+
298
+ def resource_leaves(self, items):
299
+ """Handle resource leaves: leaves type start_date [- end_date]."""
300
+ leave_type = items[0] if items else 'annual'
301
+ start_date = items[1] if len(items) > 1 else None
302
+ end_date = items[2] if len(items) > 2 else start_date
303
+ return ('leaves', {
304
+ 'type': leave_type,
305
+ 'start': start_date,
306
+ 'end': end_date
307
+ })
308
+
309
+ def resource_flags(self, items):
310
+ return ('flags', [self._get_value(i) for i in items])
311
+
312
+ def resource_vacation(self, items):
313
+ """Handle resource vacation: vacation start_date [- end_date]."""
314
+ start_date = items[0] if items else None
315
+ end_date = items[1] if len(items) > 1 else start_date
316
+ return ('vacation', {
317
+ 'start': start_date,
318
+ 'end': end_date
319
+ })
320
+
321
+ def resource_booking(self, items):
322
+ """Handle resource booking: booking STRING date duration_value."""
323
+ name = self._get_value(items[0])
324
+ start = items[1] if len(items) > 1 else None
325
+ duration = self._get_value(items[2]) if len(items) > 2 else '0h'
326
+ return ('booking', {
327
+ 'name': name,
328
+ 'start': start,
329
+ 'duration': duration
330
+ })
331
+
332
+ def resource_workinghours(self, items):
333
+ """Handle resource workinghours: workinghours mon, tue, ... 08:00 - 17:00 or shift_id."""
334
+ if not items:
335
+ return ('workinghours', [])
336
+ # Check if it's a shift reference (ID) or a workinghours_spec (list)
337
+ item = items[0]
338
+ if isinstance(item, str):
339
+ # It's a shift ID reference
340
+ return ('workinghours_shift', item)
341
+ elif hasattr(item, 'type') and item.type == 'ID':
342
+ # It's a Token ID
343
+ return ('workinghours_shift', str(item))
344
+ else:
345
+ # It's a workinghours_spec
346
+ return ('workinghours', item)
347
+
348
+ def resource_chargeset(self, items):
349
+ """Handle resource chargeset: chargeset account_id."""
350
+ return ('chargeset', self._get_value(items[0]))
351
+
352
+ def timingresolution(self, items):
353
+ """Handle timingresolution: timingresolution duration_value."""
354
+ # Parse duration to seconds
355
+ duration = items[0] if items else '1h'
356
+ import re
357
+ match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmymin]+)', str(duration))
358
+ if match:
359
+ value = float(match.group(1))
360
+ unit = match.group(2) or 'h'
361
+ if unit == 'min':
362
+ seconds = int(value * 60)
363
+ elif unit == 'h':
364
+ seconds = int(value * 3600)
365
+ elif unit == 'd':
366
+ seconds = int(value * 86400)
367
+ else:
368
+ seconds = 3600 # default 1 hour
369
+ return ('timingresolution', seconds)
370
+ return ('timingresolution', 3600)
371
+
372
+ def workinghours(self, items):
373
+ """Handle workinghours at project or shift level."""
374
+ return ('workinghours', items[0] if items else [])
375
+
376
+ def workinghours_spec(self, items):
377
+ """Parse workinghours specification: mon, tue, ... 08:00 - 17:00, 13:00 - 14:00.
378
+
379
+ Returns a dict mapping day names to list of (start_time, end_time) tuples.
380
+ """
381
+ # items[0] is day_list (list of days)
382
+ # items[1:] are duration_range tuples
383
+ days = items[0] if items else []
384
+ ranges = list(items[1:]) if len(items) > 1 else []
385
+
386
+ return {'days': days, 'ranges': ranges}
387
+
388
+ def day_list(self, items):
389
+ """Parse day list: day_spec, day_spec, ..."""
390
+ all_days = []
391
+ for item in items:
392
+ if isinstance(item, list):
393
+ all_days.extend(item)
394
+ else:
395
+ all_days.append(item)
396
+ return all_days
397
+
398
+ def day_spec(self, items):
399
+ """Parse day spec: single day or day range like mon - fri."""
400
+ day_order = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
401
+
402
+ if len(items) == 1:
403
+ # Single day
404
+ day = self._get_value(items[0]).lower()
405
+ return [day]
406
+ else:
407
+ # Day range like mon - fri
408
+ start_day = self._get_value(items[0]).lower()
409
+ end_day = self._get_value(items[1]).lower()
410
+
411
+ start_idx = day_order.index(start_day)
412
+ end_idx = day_order.index(end_day)
413
+
414
+ # Handle wrap-around if needed (e.g., fri - mon)
415
+ if start_idx <= end_idx:
416
+ return day_order[start_idx:end_idx + 1]
417
+ else:
418
+ # Wrap around (unusual but supported)
419
+ return day_order[start_idx:] + day_order[:end_idx + 1]
420
+
421
+ def duration_range(self, items):
422
+ """Parse duration range: TIME - TIME."""
423
+ start_time = self._get_value(items[0]) if items else '09:00'
424
+ end_time = self._get_value(items[1]) if len(items) > 1 else '17:00'
425
+ return (start_time, end_time)
426
+
427
+ def leaves_type(self, items):
428
+ """Handle leaves type: annual, sick, holiday, special, unpaid."""
429
+ return self._get_value(items[0]) if items else 'annual'
430
+
431
+ # Task
432
+ def task(self, items):
433
+ t_id = self._get_value(items[0])
434
+ t_name = self._get_value(items[1])
435
+ body = items[2] if len(items) > 2 else []
436
+ return {
437
+ 'type': 'task',
438
+ 'id': t_id,
439
+ 'name': t_name,
440
+ 'attributes': body
441
+ }
442
+
443
+ def task_body(self, items):
444
+ return list(items)
445
+
446
+ def task_attr(self, items):
447
+ return items[0] if items else None
448
+
449
+ # Named task attribute rules
450
+ def task_start(self, items):
451
+ return ('start', items[0])
452
+
453
+ def task_end(self, items):
454
+ return ('end', items[0])
455
+
456
+ def task_effort(self, items):
457
+ return items[0] # effort_value returns a tuple
458
+
459
+ def task_duration(self, items):
460
+ return ('duration', items[0])
461
+
462
+ def task_length(self, items):
463
+ return ('length', items[0])
464
+
465
+ def task_milestone(self, items):
466
+ return ('milestone', True)
467
+
468
+ def task_scheduling(self, items):
469
+ mode = self._get_value(items[0]).lower()
470
+ # forward=True means ASAP, forward=False means ALAP
471
+ return ('forward', mode == 'asap')
472
+
473
+ def task_depends(self, items):
474
+ return items[0] # depends_list returns a tuple
475
+
476
+ def task_precedes(self, items):
477
+ return ('precedes', items[0][1] if isinstance(items[0], tuple) else items[0])
478
+
479
+ def task_allocate(self, items):
480
+ return items[0] # allocate_spec returns a tuple
481
+
482
+ def task_responsible(self, items):
483
+ return ('responsible', self._get_value(items[0]))
484
+
485
+ def task_priority(self, items):
486
+ return ('priority', int(self._get_value(items[0])))
487
+
488
+ def task_complete(self, items):
489
+ return ('complete', float(self._get_value(items[0])))
490
+
491
+ def task_note(self, items):
492
+ return ('note', self._get_value(items[0]))
493
+
494
+ def task_chargeset(self, items):
495
+ return ('chargeset', self._get_value(items[0]))
496
+
497
+ def task_purge_chargeset(self, items):
498
+ return ('purge_chargeset', True)
499
+
500
+ def task_charge(self, items):
501
+ return ('charge', (float(self._get_value(items[0])), self._get_value(items[1]) if len(items) > 1 else None))
502
+
503
+ def task_limits(self, items):
504
+ return ('limits', items[0] if items else [])
505
+
506
+ def task_journalentry(self, items):
507
+ # items: date, optional headline (STRING), journal_body
508
+ date = items[0] if items else None
509
+ headline = None
510
+ body = {}
511
+
512
+ for item in items[1:]:
513
+ if isinstance(item, str) or (hasattr(item, 'type') and item.type == 'STRING'):
514
+ headline = self._get_value(item)
515
+ elif isinstance(item, dict):
516
+ body = item
517
+
518
+ return ('journalentry', {'date': date, 'headline': headline, 'body': body})
519
+
520
+ def journal_body(self, items):
521
+ # Collect all journal attributes into a dict
522
+ result = {'author': None, 'alert': 'green', 'summary': None, 'details': None}
523
+ for item in items:
524
+ if isinstance(item, tuple):
525
+ key, value = item
526
+ result[key] = value
527
+ return result
528
+
529
+ def journal_attr(self, items):
530
+ # Pass through the inner journal_* rule result
531
+ return items[0] if items else None
532
+
533
+ def journal_author(self, items):
534
+ return ('author', self._get_value(items[0]))
535
+
536
+ def journal_alert(self, items):
537
+ return ('alert', items[0] if items else 'green')
538
+
539
+ def journal_summary(self, items):
540
+ value = items[0] if items else None
541
+ return ('summary', self._extract_text(value))
542
+
543
+ def journal_details(self, items):
544
+ value = items[0] if items else None
545
+ return ('details', self._extract_text(value))
546
+
547
+ def rich_text(self, items):
548
+ # Rich text is wrapped in -8<- ... ->8-
549
+ # The RICH_TEXT_BLOCK token contains the delimiters and content
550
+ if items:
551
+ text = self._get_value(items[0])
552
+ # Strip the -8<- and ->8- markers
553
+ if text.startswith('-8<-'):
554
+ text = text[4:]
555
+ if text.endswith('->8-'):
556
+ text = text[:-4]
557
+ return text.strip()
558
+ return ''
559
+
560
+ def _extract_text(self, value):
561
+ """Extract text from a string or rich_text result."""
562
+ if value is None:
563
+ return None
564
+ if isinstance(value, str):
565
+ return value
566
+ if hasattr(value, 'type') and value.type == 'STRING':
567
+ return self._get_value(value)
568
+ # If it's already processed rich_text
569
+ return str(value) if value else None
570
+
571
+ def alert_level(self, items):
572
+ # items[0] is a Token with the alert level value (green/yellow/red)
573
+ return self._get_value(items[0]) if items else 'green'
574
+
575
+ def task_flags(self, items):
576
+ return ('flags', [self._get_value(i) for i in items])
577
+
578
+ def scenario_attr(self, items):
579
+ """Handle scenario-specific attribute like 'delayed:effort 40d'."""
580
+ scenario_id = self._get_value(items[0])
581
+ attr_data = items[1] # scenario_specific_attr result
582
+ return ('scenario_attr', (scenario_id, attr_data))
583
+
584
+ def scenario_specific_attr(self, items):
585
+ """Handle the attribute part of scenario-specific attribute."""
586
+ # items[0] is the result from scenario_start/end/effort/etc
587
+ return items[0] if items else None
588
+
589
+ def scenario_start(self, items):
590
+ """Handle scenario-specific start attribute."""
591
+ return ('start', items[0]) # items[0] is the date
592
+
593
+ def scenario_end(self, items):
594
+ """Handle scenario-specific end attribute."""
595
+ return ('end', items[0])
596
+
597
+ def scenario_effort(self, items):
598
+ """Handle scenario-specific effort attribute."""
599
+ return items[0] # effort_value already returns ('effort', value)
600
+
601
+ def scenario_duration(self, items):
602
+ """Handle scenario-specific duration attribute."""
603
+ return ('duration', items[0])
604
+
605
+ def scenario_length(self, items):
606
+ """Handle scenario-specific length attribute."""
607
+ return ('length', items[0])
608
+
609
+ # Task attribute helpers
610
+ def effort_value(self, items):
611
+ num = float(self._get_value(items[0]))
612
+ unit = self._get_value(items[1])
613
+ # Convert to hours (the base unit internally)
614
+ # d=day (8h), w=week (40h), h=hour, m=minute, y=year (2080h)
615
+ multipliers = {
616
+ 'd': 8,
617
+ 'w': 40,
618
+ 'h': 1,
619
+ 'm': 1/60,
620
+ 'y': 2080,
621
+ 'min': 1/60
622
+ }
623
+ hours = num * multipliers.get(unit.lower(), 1)
624
+ return ('effort', hours)
625
+
626
+ def duration_value(self, items):
627
+ num = self._get_value(items[0])
628
+ unit = self._get_value(items[1])
629
+ return f"{num}{unit}"
630
+
631
+ def depends_list(self, items):
632
+ # Items are now dependency dicts with ref and optional gap
633
+ return ('depends', list(items))
634
+
635
+ def depends_item(self, items):
636
+ # First item is the DEPENDS_REF, optional second is depends_options dict
637
+ ref = self._get_value(items[0])
638
+ dep = {'ref': ref}
639
+ if len(items) > 1 and items[1]:
640
+ dep.update(items[1])
641
+ return dep
642
+
643
+ def depends_options(self, items):
644
+ result = {}
645
+ for item in items:
646
+ if isinstance(item, dict):
647
+ result.update(item)
648
+ return result
649
+
650
+ def dep_gapduration(self, items):
651
+ return {'gapduration': self._get_value(items[0])}
652
+
653
+ def dep_gaplength(self, items):
654
+ return {'gaplength': self._get_value(items[0])}
655
+
656
+ def dep_maxgapduration(self, items):
657
+ return {'maxgapduration': self._get_value(items[0])}
658
+
659
+ def dep_onend(self, items):
660
+ return {'onend': True}
661
+
662
+ def dep_onstart(self, items):
663
+ return {'onstart': True}
664
+
665
+ def allocate_spec(self, items):
666
+ resources = []
667
+ options = {}
668
+ for item in items:
669
+ if isinstance(item, Token):
670
+ resources.append(item.value)
671
+ elif isinstance(item, dict):
672
+ # allocate_options dict
673
+ options.update(item)
674
+ # If there are alternatives, include them in the allocation structure
675
+ if options:
676
+ return ('allocate', {'resources': resources, 'options': options})
677
+ return ('allocate', resources)
678
+
679
+ def allocate_options(self, items):
680
+ """Process allocation options like persistent, mandatory, alternative."""
681
+ result = {}
682
+ for item in items:
683
+ if isinstance(item, dict):
684
+ result.update(item)
685
+ elif isinstance(item, list):
686
+ # Flatten nested lists from allocate_option
687
+ for subitem in item:
688
+ if isinstance(subitem, dict):
689
+ result.update(subitem)
690
+ return result
691
+
692
+ def allocate_option(self, items):
693
+ """
694
+ Process a single allocation option.
695
+
696
+ The grammar literals ("alternative", "persistent", etc.) are filtered out,
697
+ so we detect the option type by the structure:
698
+ - Empty items -> persistent or mandatory (no IDs)
699
+ - ID tokens -> alternative (list of resource IDs)
700
+ - Dict with limits -> limits
701
+ """
702
+ if not items:
703
+ # This shouldn't happen for well-formed input
704
+ return {}
705
+
706
+ # Check if it's a limits block (dict)
707
+ if isinstance(items[0], dict):
708
+ return items[0]
709
+
710
+ # Check if items are ID tokens -> alternative resources
711
+ alternatives = []
712
+ for item in items:
713
+ if isinstance(item, Token) and item.type == 'ID':
714
+ alternatives.append(item.value)
715
+
716
+ if alternatives:
717
+ return {'alternative': alternatives}
718
+
719
+ # Persistent and mandatory have no child items in the parse tree
720
+ # They're handled at the grammar level as literals
721
+ return {}
722
+
723
+ # Account
724
+ def account(self, items):
725
+ a_id = self._get_value(items[0])
726
+ a_name = self._get_value(items[1])
727
+ body = items[2] if len(items) > 2 else []
728
+ return {
729
+ 'type': 'account',
730
+ 'id': a_id,
731
+ 'name': a_name,
732
+ 'attributes': body
733
+ }
734
+
735
+ def account_body(self, items):
736
+ return list(items)
737
+
738
+ def account_attr(self, items):
739
+ return items[0] if items else None
740
+
741
+ # Shift
742
+ def shift(self, items):
743
+ s_id = self._get_value(items[0])
744
+ # Name is optional (STRING?)
745
+ # Body is the last item (a list)
746
+ if len(items) >= 2 and isinstance(items[-1], list):
747
+ body = items[-1]
748
+ s_name = self._get_value(items[1]) if len(items) > 2 else s_id
749
+ else:
750
+ body = []
751
+ s_name = self._get_value(items[1]) if len(items) > 1 else s_id
752
+ return {
753
+ 'type': 'shift',
754
+ 'id': s_id,
755
+ 'name': s_name,
756
+ 'attributes': body
757
+ }
758
+
759
+ def shift_body(self, items):
760
+ return list(items)
761
+
762
+ def shift_attr(self, items):
763
+ # shift_attr comes from workinghours workinghours_spec or leaves
764
+ # The workinghours handler returns ('workinghours', spec)
765
+ # But for shifts, the grammar directly uses workinghours_spec
766
+ if items and isinstance(items[0], dict):
767
+ # It's a workinghours_spec dict - wrap it as a tuple
768
+ return ('workinghours', items[0])
769
+ return items[0] if items else None
770
+
771
+ # Reports
772
+ def report_definition(self, items):
773
+ return items[0] if items else None
774
+
775
+ def textreport(self, items):
776
+ return self._parse_report('textreport', items)
777
+
778
+ def taskreport(self, items):
779
+ return self._parse_report('taskreport', items)
780
+
781
+ def resourcereport(self, items):
782
+ return self._parse_report('resourcereport', items)
783
+
784
+ def _parse_report(self, report_type, items):
785
+ r_id = None
786
+ r_name = None
787
+ body = []
788
+ for item in items:
789
+ if isinstance(item, Token):
790
+ if item.type == 'ID':
791
+ r_id = item.value
792
+ elif item.type == 'STRING':
793
+ r_name = item.value.strip('"')
794
+ elif isinstance(item, list):
795
+ body = item
796
+ return {
797
+ 'type': report_type,
798
+ 'id': r_id,
799
+ 'name': r_name,
800
+ 'attributes': body
801
+ }
802
+
803
+ def textreport_body(self, items):
804
+ return list(items)
805
+
806
+ def textreport_attr(self, items):
807
+ return items[0] if items else None
808
+
809
+ def textreport_header(self, items):
810
+ return ('header', self._get_value(items[0]))
811
+
812
+ def textreport_footer(self, items):
813
+ return ('footer', self._get_value(items[0]))
814
+
815
+ def textreport_center(self, items):
816
+ return ('center', self._get_value(items[0]))
817
+
818
+ def textreport_left(self, items):
819
+ return ('left', self._get_value(items[0]))
820
+
821
+ def textreport_right(self, items):
822
+ return ('right', self._get_value(items[0]))
823
+
824
+ def textreport_formats(self, items):
825
+ # items[0] is the result from format_list which is already ('formats', [...])
826
+ return items[0] if items else ('formats', [])
827
+
828
+ def textreport_title(self, items):
829
+ return ('title', self._get_value(items[0]))
830
+
831
+ def taskreport_body(self, items):
832
+ return list(items)
833
+
834
+ def taskreport_attr(self, items):
835
+ return items[0] if items else None
836
+
837
+ def taskreport_header(self, items):
838
+ return ('header', self._get_value(items[0]))
839
+
840
+ def taskreport_footer(self, items):
841
+ return ('footer', self._get_value(items[0]))
842
+
843
+ def taskreport_headline(self, items):
844
+ return ('headline', self._get_value(items[0]))
845
+
846
+ def taskreport_caption(self, items):
847
+ return ('caption', self._get_value(items[0]))
848
+
849
+ def taskreport_columns(self, items):
850
+ return items[0] if items else ('columns', [])
851
+
852
+ def taskreport_timeformat(self, items):
853
+ return ('timeFormat', self._get_value(items[0]))
854
+
855
+ def taskreport_loadunit(self, items):
856
+ return ('loadUnit', self._get_value(items[0]))
857
+
858
+ def taskreport_hideresource(self, items):
859
+ return ('hideResource', self._get_value(items[0]))
860
+
861
+ def taskreport_hidetask(self, items):
862
+ return ('hideTask', self._get_value(items[0]))
863
+
864
+ def taskreport_sorttasks(self, items):
865
+ return items[0] if items else ('sort', [])
866
+
867
+ def taskreport_sortresources(self, items):
868
+ return items[0] if items else ('sort', [])
869
+
870
+ def taskreport_scenarios(self, items):
871
+ return ('scenarios', [self._get_value(i) for i in items])
872
+
873
+ def taskreport_taskroot(self, items):
874
+ return ('taskRoot', self._get_value(items[0]))
875
+
876
+ def taskreport_period(self, items):
877
+ return items[0] if items else ('period', None)
878
+
879
+ def taskreport_balance(self, items):
880
+ return ('balance', [self._get_value(i) for i in items])
881
+
882
+ def taskreport_journalmode(self, items):
883
+ return ('journalMode', self._get_value(items[0]))
884
+
885
+ def taskreport_journalattributes(self, items):
886
+ return ('journalAttributes', [self._get_value(i) for i in items])
887
+
888
+ def taskreport_formats(self, items):
889
+ # items[0] is the result from format_list: ('formats', [...])
890
+ if items and isinstance(items[0], tuple) and items[0][0] == 'formats':
891
+ return items[0] # Already a properly formatted tuple
892
+ return ('formats', [self._get_value(i) for i in items])
893
+
894
+ def taskreport_leaftasksonly(self, items):
895
+ val = self._get_value(items[0])
896
+ # Convert string to boolean
897
+ if isinstance(val, str):
898
+ val = val.lower() in ('true', 'yes', '1')
899
+ return ('leafTasksOnly', val)
900
+
901
+ def resourcereport_body(self, items):
902
+ return list(items)
903
+
904
+ def resourcereport_attr(self, items):
905
+ return items[0] if items else None
906
+
907
+ def resourcereport_header(self, items):
908
+ return ('header', self._get_value(items[0]))
909
+
910
+ def resourcereport_footer(self, items):
911
+ return ('footer', self._get_value(items[0]))
912
+
913
+ def resourcereport_headline(self, items):
914
+ return ('headline', self._get_value(items[0]))
915
+
916
+ def resourcereport_columns(self, items):
917
+ return items[0] if items else ('columns', [])
918
+
919
+ def resourcereport_loadunit(self, items):
920
+ return ('loadUnit', self._get_value(items[0]))
921
+
922
+ def resourcereport_hideresource(self, items):
923
+ return ('hideResource', self._get_value(items[0]))
924
+
925
+ def resourcereport_hidetask(self, items):
926
+ return ('hideTask', self._get_value(items[0]))
927
+
928
+ def resourcereport_sorttasks(self, items):
929
+ return items[0] if items else ('sort', [])
930
+
931
+ def resourcereport_sortresources(self, items):
932
+ return items[0] if items else ('sort', [])
933
+
934
+ def resourcereport_scenarios(self, items):
935
+ return ('scenarios', [self._get_value(i) for i in items])
936
+
937
+ # Column specifications
938
+ def column_list(self, items):
939
+ """Parse column list into list of column specs."""
940
+ return ('columns', [item for item in items if item])
941
+
942
+ def column_spec(self, items):
943
+ """Parse a single column specification."""
944
+ col_id = self._get_value(items[0])
945
+ options = {}
946
+ if len(items) > 1 and items[1]:
947
+ options = items[1]
948
+ return {'id': col_id, 'options': options}
949
+
950
+ def column_options(self, items):
951
+ """Parse column options into a dict."""
952
+ result = {}
953
+ for item in items:
954
+ if isinstance(item, tuple):
955
+ result[item[0]] = item[1]
956
+ elif isinstance(item, Token):
957
+ # Macro reference or similar
958
+ result['macro'] = self._get_value(item)
959
+ return result
960
+
961
+ def column_option(self, items):
962
+ """Parse a single column option."""
963
+ if not items:
964
+ return None
965
+ first = items[0]
966
+ if isinstance(first, Token):
967
+ if first.type == 'MACRO_REF':
968
+ return ('macro', self._get_value(first))
969
+ # Token is often the keyword like 'title', 'width' etc
970
+ key = self._get_value(first)
971
+ value = self._get_value(items[1]) if len(items) > 1 else None
972
+ return (key, value)
973
+ return items[0] if items else None
974
+
975
+ # Sort specifications
976
+ def sort_list(self, items):
977
+ """Parse sort list."""
978
+ return ('sort', [item for item in items if item])
979
+
980
+ def sort_item(self, items):
981
+ """Parse a single sort item."""
982
+ return self._get_value(items[0]) if items else None
983
+
984
+ # Format list
985
+ def format_list(self, items):
986
+ """Parse formats list."""
987
+ return ('formats', [self._get_value(i) for i in items])
988
+
989
+ # Period specification
990
+ def period_spec(self, items):
991
+ """Parse period specification."""
992
+ return ('period', self._get_value(items[0]) if items else None)
993
+
994
+ # Navigator
995
+ def navigator(self, items):
996
+ n_id = self._get_value(items[0])
997
+ body = items[1] if len(items) > 1 else []
998
+ return {
999
+ 'type': 'navigator',
1000
+ 'id': n_id,
1001
+ 'attributes': body
1002
+ }
1003
+
1004
+ def navigator_body(self, items):
1005
+ return list(items)
1006
+
1007
+ def navigator_attr(self, items):
1008
+ return items[0] if items else None
1009
+
1010
+ # Common
1011
+ def date(self, items):
1012
+ val = self._get_value(items[0])
1013
+ try:
1014
+ return datetime.strptime(val, "%Y-%m-%d")
1015
+ except ValueError:
1016
+ return datetime.strptime(val, "%Y-%m-%d-%H:%M")
1017
+
1018
+ def _get_value(self, item):
1019
+ """Extract value from Token or string."""
1020
+ if isinstance(item, Token):
1021
+ val = item.value
1022
+ if val.startswith('"') and val.endswith('"'):
1023
+ return val[1:-1]
1024
+ return val
1025
+ elif isinstance(item, str):
1026
+ if item.startswith('"') and item.endswith('"'):
1027
+ return item[1:-1]
1028
+ return item
1029
+ return item
1030
+
1031
+
1032
+ class ModelBuilder:
1033
+ """Build the Project model from the parsed data."""
1034
+
1035
+ def __init__(self):
1036
+ self._pending_depends = [] # Store (task, depends_list) for later resolution
1037
+ self._pending_precedes = [] # Store (task, precedes_list) for later resolution
1038
+
1039
+ def build(self, data):
1040
+ """Build a Project from parsed data."""
1041
+ if not data or not data.get('project'):
1042
+ raise ValueError("No project definition found")
1043
+
1044
+ proj_data = data['project']
1045
+ timeframe = proj_data.get('timeframe', {})
1046
+ start_date = timeframe.get('start')
1047
+ duration_str = timeframe.get('duration')
1048
+
1049
+ # Create project
1050
+ project = Project(
1051
+ proj_data['id'],
1052
+ proj_data['name'],
1053
+ None # version is not used like this
1054
+ )
1055
+
1056
+ # Set project start and end dates
1057
+ if start_date:
1058
+ project['start'] = start_date
1059
+ # Calculate end date from duration if provided
1060
+ if duration_str:
1061
+ from dateutil.relativedelta import relativedelta
1062
+ import re
1063
+ match = re.match(r'(\d+)([dwmy])', duration_str)
1064
+ if match:
1065
+ amount = int(match.group(1))
1066
+ unit = match.group(2)
1067
+ if unit == 'd':
1068
+ end_date = start_date + relativedelta(days=amount)
1069
+ elif unit == 'w':
1070
+ end_date = start_date + relativedelta(weeks=amount)
1071
+ elif unit == 'm':
1072
+ end_date = start_date + relativedelta(months=amount)
1073
+ elif unit == 'y':
1074
+ end_date = start_date + relativedelta(years=amount)
1075
+ else:
1076
+ end_date = start_date
1077
+ project['end'] = end_date
1078
+
1079
+ # Apply project attributes
1080
+ self._apply_project_attributes(project, proj_data.get('attributes', []))
1081
+
1082
+ # Apply global attributes
1083
+ self._apply_global_attributes(project, data.get('global_attributes', []))
1084
+
1085
+ # Apply property declarations (resources, tasks, accounts)
1086
+ for prop in data.get('property_declarations', []):
1087
+ self._create_property(project, prop)
1088
+
1089
+ # Resolve dependencies after all tasks are created
1090
+ self._resolve_dependencies(project)
1091
+
1092
+ # Resolve precedes relationships (convert to dependencies on target tasks)
1093
+ self._resolve_precedes(project)
1094
+
1095
+ # Inherit attributes from parents for all tasks
1096
+ self._inherit_all_attributes(project)
1097
+
1098
+ # Create reports
1099
+ for report_data in data.get('reports', []):
1100
+ self._create_report(project, report_data)
1101
+
1102
+ return project
1103
+
1104
+ def _inherit_all_attributes(self, project):
1105
+ """Inherit attributes from parent nodes for all tasks and resources."""
1106
+ # Process tasks in tree order (parents before children)
1107
+ def inherit_recursive(node):
1108
+ node.inheritAttributes()
1109
+ for child in node.children:
1110
+ inherit_recursive(child)
1111
+
1112
+ # Get top-level items (no parent)
1113
+ for task in project.tasks:
1114
+ if not task.parent:
1115
+ inherit_recursive(task)
1116
+
1117
+ for resource in project.resources:
1118
+ if not resource.parent:
1119
+ inherit_recursive(resource)
1120
+
1121
+ def _resolve_dependencies(self, project):
1122
+ """Resolve task dependency references to actual Task objects."""
1123
+ for task, depends_list in self._pending_depends:
1124
+ resolved = []
1125
+ for dep_item in depends_list:
1126
+ # dep_item can be a dict with 'ref' key or a string (for backwards compat)
1127
+ if isinstance(dep_item, dict):
1128
+ dep_ref = dep_item.get('ref', '')
1129
+ gapduration = dep_item.get('gapduration')
1130
+ gaplength = dep_item.get('gaplength')
1131
+ maxgapduration = dep_item.get('maxgapduration')
1132
+ onstart = dep_item.get('onstart', False)
1133
+ onend = dep_item.get('onend', False)
1134
+ else:
1135
+ dep_ref = dep_item
1136
+ gapduration = None
1137
+ gaplength = None
1138
+ maxgapduration = None
1139
+ onstart = False
1140
+ onend = False
1141
+
1142
+ dep_task = self._resolve_task_reference(project, task, dep_ref)
1143
+ if dep_task:
1144
+ # Store as dict if we have gap info or onstart/onend, else just the task
1145
+ if gapduration or gaplength or maxgapduration or onstart or onend:
1146
+ resolved.append({
1147
+ 'task': dep_task,
1148
+ 'gapduration': gapduration,
1149
+ 'gaplength': gaplength,
1150
+ 'maxgapduration': maxgapduration,
1151
+ 'onstart': onstart,
1152
+ 'onend': onend
1153
+ })
1154
+ else:
1155
+ resolved.append(dep_task)
1156
+ if resolved:
1157
+ # Set dependencies for all scenarios
1158
+ for scIdx in range(project.scenarioCount()):
1159
+ task[('depends', scIdx)] = resolved
1160
+
1161
+ def _resolve_precedes(self, project):
1162
+ """Resolve precedes relationships by adding dependencies to target tasks.
1163
+
1164
+ If task A precedes task B, then B depends on A.
1165
+ This is the inverse of the 'depends' relationship.
1166
+ """
1167
+ for source_task, precedes_list in self._pending_precedes:
1168
+ for prec_item in precedes_list:
1169
+ # prec_item can be a dict with 'ref' key or a string
1170
+ if isinstance(prec_item, dict):
1171
+ prec_ref = prec_item.get('ref', '')
1172
+ else:
1173
+ prec_ref = prec_item
1174
+
1175
+ target_task = self._resolve_task_reference(project, source_task, prec_ref)
1176
+ if target_task:
1177
+ # Add source_task as a dependency of target_task
1178
+ for scIdx in range(project.scenarioCount()):
1179
+ existing_deps = target_task.get('depends', scIdx) or []
1180
+ if not isinstance(existing_deps, list):
1181
+ existing_deps = [existing_deps] if existing_deps else []
1182
+ # Check if source_task is already in dependencies
1183
+ already_exists = False
1184
+ for dep in existing_deps:
1185
+ dep_task = dep.get('task') if isinstance(dep, dict) else dep
1186
+ if dep_task is source_task:
1187
+ already_exists = True
1188
+ break
1189
+ if not already_exists:
1190
+ existing_deps.append(source_task)
1191
+ target_task[('depends', scIdx)] = existing_deps
1192
+
1193
+ def _resolve_task_reference(self, project, from_task, ref):
1194
+ """Resolve a task reference string to a Task object.
1195
+
1196
+ Reference formats:
1197
+ - "!taskid" - sibling (same parent)
1198
+ - "!!taskid" - uncle (parent's sibling)
1199
+ - "taskid" - from project root
1200
+ - "parent.child" - path from root
1201
+ """
1202
+ if not ref:
1203
+ return None
1204
+
1205
+ # Count leading exclamation marks to determine scope
1206
+ level = 0
1207
+ while ref.startswith('!'):
1208
+ level += 1
1209
+ ref = ref[1:]
1210
+
1211
+ # Find base task to search from
1212
+ if level > 0:
1213
+ # Go up level times from current task's parent
1214
+ base = from_task.parent
1215
+ for _ in range(level - 1):
1216
+ if base and base.parent:
1217
+ base = base.parent
1218
+ else:
1219
+ base = None
1220
+ break
1221
+ else:
1222
+ base = None # Search from root
1223
+
1224
+ # Now find the task by ID
1225
+ # Handle path references like "parent.child"
1226
+ parts = ref.split('.')
1227
+
1228
+ if base:
1229
+ # Search in base's children
1230
+ current = base
1231
+ for part in parts:
1232
+ found = None
1233
+ for child in current.children:
1234
+ if child.id == part:
1235
+ found = child
1236
+ break
1237
+ if found:
1238
+ current = found
1239
+ else:
1240
+ return None
1241
+ return current
1242
+ else:
1243
+ # Search from project root
1244
+ for task in project.tasks:
1245
+ if task.id == parts[0]:
1246
+ if len(parts) == 1:
1247
+ return task
1248
+ # Navigate path
1249
+ current = task
1250
+ for part in parts[1:]:
1251
+ found = None
1252
+ for child in current.children:
1253
+ if child.id == part:
1254
+ found = child
1255
+ break
1256
+ if found:
1257
+ current = found
1258
+ else:
1259
+ return None
1260
+ return current
1261
+ return None
1262
+
1263
+ def _apply_project_attributes(self, project, attributes):
1264
+ """Apply attributes to the project."""
1265
+ for attr in attributes:
1266
+ if attr is None:
1267
+ continue
1268
+ if isinstance(attr, tuple):
1269
+ key, value = attr
1270
+ if key == 'scenario':
1271
+ self._create_scenario(project, value)
1272
+ elif key == 'extend':
1273
+ pass # Handle extensions later
1274
+ else:
1275
+ try:
1276
+ project[key] = value
1277
+ except (ValueError, KeyError):
1278
+ pass
1279
+
1280
+ def _apply_global_attributes(self, project, attributes):
1281
+ """Apply global attributes to the project."""
1282
+ from scriptplan.core.leave import Leave
1283
+ from scriptplan.utils.time import TimeInterval
1284
+
1285
+ for attr in attributes:
1286
+ if attr is None:
1287
+ continue
1288
+ if isinstance(attr, tuple):
1289
+ key, value = attr
1290
+ if key == 'leaves':
1291
+ # Global leaves - convert dict to Leave object
1292
+ leave_type = value.get('type', 'holiday')
1293
+ start_date = value.get('start')
1294
+ # For single-day holidays, end_date might be None
1295
+ end_date = value.get('end')
1296
+ if end_date is None:
1297
+ # Single day - end is start + 1 day
1298
+ from datetime import timedelta
1299
+ end_date = start_date + timedelta(days=1)
1300
+
1301
+ if start_date:
1302
+ interval = TimeInterval(start_date, end_date)
1303
+ type_idx = Leave.Types.get(leave_type, 1) # Default to 'holiday' (1)
1304
+ leave = Leave(interval, type_idx)
1305
+
1306
+ # Add to project's leaves list
1307
+ existing = project.attributes.get('leaves', [])
1308
+ if not isinstance(existing, list):
1309
+ existing = [existing] if existing else []
1310
+ existing.append(leave)
1311
+ project.attributes['leaves'] = existing
1312
+ elif key == 'vacation':
1313
+ # Global vacation - similar to leaves but always 'holiday' type
1314
+ start_date = value.get('start')
1315
+ end_date = value.get('end')
1316
+ # If end_date equals start_date (single day vacation), extend to next day
1317
+ from datetime import timedelta
1318
+ if end_date is None or end_date == start_date:
1319
+ end_date = start_date + timedelta(days=1)
1320
+
1321
+ if start_date:
1322
+ interval = TimeInterval(start_date, end_date)
1323
+ type_idx = Leave.Types.get('holiday', 1)
1324
+ leave = Leave(interval, type_idx)
1325
+
1326
+ # Add to project's vacations/leaves list
1327
+ existing = project.attributes.get('vacations', [])
1328
+ if not isinstance(existing, list):
1329
+ existing = [existing] if existing else []
1330
+ existing.append(leave)
1331
+ project.attributes['vacations'] = existing
1332
+ else:
1333
+ try:
1334
+ project[key] = value
1335
+ except (ValueError, KeyError):
1336
+ pass
1337
+
1338
+ def _create_scenario(self, project, scenario_data, parent=None):
1339
+ """Create a scenario in the project.
1340
+
1341
+ Args:
1342
+ project: The project
1343
+ scenario_data: Dict with 'id', 'name', 'children'
1344
+ parent: Parent scenario for nested scenarios
1345
+ """
1346
+ from scriptplan.core.scenario import Scenario
1347
+
1348
+ s_id = scenario_data.get('id')
1349
+ s_name = scenario_data.get('name', '').strip('"')
1350
+ children = scenario_data.get('children', [])
1351
+
1352
+ # Clear default scenario on first scenario definition
1353
+ if parent is None and not hasattr(self, '_scenarios_cleared'):
1354
+ # Remove the default 'plan' scenario
1355
+ default_plan = project.scenarios['plan']
1356
+ if default_plan:
1357
+ project.scenarios.removeProperty(default_plan)
1358
+ self._scenarios_cleared = True
1359
+
1360
+ # Create the scenario
1361
+ scenario = Scenario(project, s_id, s_name, parent)
1362
+
1363
+ # Create nested child scenarios
1364
+ for child in children:
1365
+ if isinstance(child, tuple) and child[0] == 'scenario':
1366
+ self._create_scenario(project, child[1], scenario)
1367
+
1368
+ def _create_property(self, parent, prop_data):
1369
+ """Create a property (resource, task, account) in the parent."""
1370
+ if not isinstance(prop_data, dict):
1371
+ return
1372
+
1373
+ prop_type = prop_data.get('type')
1374
+ prop_id = prop_data.get('id')
1375
+ prop_name = prop_data.get('name')
1376
+ attributes = prop_data.get('attributes', [])
1377
+
1378
+ # Determine project reference
1379
+ project = parent if isinstance(parent, Project) else parent.project
1380
+
1381
+ if prop_type == 'task':
1382
+ obj = Task(
1383
+ project,
1384
+ prop_id,
1385
+ prop_name,
1386
+ parent if isinstance(parent, Task) else None
1387
+ )
1388
+ elif prop_type == 'resource':
1389
+ obj = Resource(
1390
+ project,
1391
+ prop_id,
1392
+ prop_name,
1393
+ parent if isinstance(parent, Resource) else None
1394
+ )
1395
+ elif prop_type == 'account':
1396
+ # Skip accounts for now - need Account class
1397
+ return
1398
+ elif prop_type == 'shift':
1399
+ from scriptplan.core.shift import Shift
1400
+ obj = Shift(
1401
+ project,
1402
+ prop_id,
1403
+ prop_name,
1404
+ parent if isinstance(parent, Shift) else None
1405
+ )
1406
+ else:
1407
+ return
1408
+
1409
+ # Apply attributes to the created object
1410
+ self._apply_property_attributes(obj, attributes, prop_type)
1411
+
1412
+ def _apply_property_attributes(self, obj, attributes, prop_type):
1413
+ """Apply attributes to a property object."""
1414
+ for attr in attributes:
1415
+ if attr is None:
1416
+ continue
1417
+ if isinstance(attr, dict):
1418
+ # Nested property (e.g., nested resource or task)
1419
+ self._create_property(obj, attr)
1420
+ elif isinstance(attr, tuple):
1421
+ key, value = attr
1422
+ if key == 'email':
1423
+ obj['email'] = value
1424
+ elif key == 'rate':
1425
+ # Set for all scenarios
1426
+ for scIdx in range(obj.project.scenarioCount()):
1427
+ obj[('rate', scIdx)] = value
1428
+ elif key == 'efficiency':
1429
+ # Set for all scenarios
1430
+ for scIdx in range(obj.project.scenarioCount()):
1431
+ obj[('efficiency', scIdx)] = value
1432
+ elif key == 'timezone':
1433
+ # Set for all scenarios
1434
+ for scIdx in range(obj.project.scenarioCount()):
1435
+ obj[('timezone', scIdx)] = value
1436
+ elif key == 'effort':
1437
+ # Set for all scenarios (no prefix means apply to all)
1438
+ for scIdx in range(obj.project.scenarioCount()):
1439
+ obj[('effort', scIdx)] = value
1440
+ elif key == 'depends':
1441
+ # Store for later resolution (after all tasks created)
1442
+ self._pending_depends.append((obj, value))
1443
+ elif key == 'precedes':
1444
+ # Store for later resolution - precedes creates reverse dependencies
1445
+ # If A precedes B, then B depends on A
1446
+ self._pending_precedes.append((obj, value))
1447
+ elif key == 'allocate':
1448
+ # Set for all scenarios
1449
+ for scIdx in range(obj.project.scenarioCount()):
1450
+ obj[('allocate', scIdx)] = value
1451
+ elif key == 'start':
1452
+ # Set for all scenarios
1453
+ for scIdx in range(obj.project.scenarioCount()):
1454
+ obj[('start', scIdx)] = value
1455
+ elif key == 'end':
1456
+ # Set for all scenarios
1457
+ for scIdx in range(obj.project.scenarioCount()):
1458
+ obj[('end', scIdx)] = value
1459
+ elif key == 'milestone':
1460
+ # Set for all scenarios
1461
+ for scIdx in range(obj.project.scenarioCount()):
1462
+ obj[('milestone', scIdx)] = value
1463
+ elif key == 'flags':
1464
+ # Set flags for all scenarios (list of flag strings)
1465
+ for scIdx in range(obj.project.scenarioCount()):
1466
+ obj[('flags', scIdx)] = value
1467
+ elif key == 'priority':
1468
+ # Set for all scenarios
1469
+ for scIdx in range(obj.project.scenarioCount()):
1470
+ obj[('priority', scIdx)] = value
1471
+ elif key == 'forward':
1472
+ # Set scheduling direction for all scenarios
1473
+ for scIdx in range(obj.project.scenarioCount()):
1474
+ obj[('forward', scIdx)] = value
1475
+ # Mark that this task has explicit scheduling (not inherited from project)
1476
+ obj._explicit_scheduling = True
1477
+ elif key == 'scenario_attr':
1478
+ # Handle scenario-specific attributes like ('delayed', ('effort', 320))
1479
+ scenario_id, attr_data = value
1480
+ scenario_idx = self._get_scenario_index(obj.project, scenario_id)
1481
+ if scenario_idx is not None and attr_data:
1482
+ if isinstance(attr_data, tuple):
1483
+ attr_key, attr_value = attr_data
1484
+ obj[(attr_key, scenario_idx)] = attr_value
1485
+ elif key == 'journalentry':
1486
+ # Create a journal entry for this task
1487
+ self._create_journal_entry(obj, value)
1488
+ elif key == 'charge':
1489
+ # charge is a tuple (amount, mode) where mode is 'onstart', 'onend', or 'perday'
1490
+ amount, mode = value
1491
+ for scIdx in range(obj.project.scenarioCount()):
1492
+ obj[('charge', scIdx)] = amount
1493
+ # Note: mode (onstart/onend/perday) affects when charge is applied
1494
+ # For now we store just the amount; mode handling can be added later
1495
+ elif key == 'chargeset':
1496
+ # chargeset specifies which account to charge to
1497
+ for scIdx in range(obj.project.scenarioCount()):
1498
+ obj[('chargeset', scIdx)] = value
1499
+ elif key == 'purge_chargeset':
1500
+ # Clear inherited chargeset
1501
+ for scIdx in range(obj.project.scenarioCount()):
1502
+ obj[('chargeset', scIdx)] = []
1503
+ elif key == 'leaves':
1504
+ # Resource leaves - create Leave objects and store on resource
1505
+ from scriptplan.core.leave import Leave
1506
+ from scriptplan.utils.time import TimeInterval
1507
+
1508
+ leave_type = value.get('type', 'annual')
1509
+ start_date = value.get('start')
1510
+ end_date = value.get('end', start_date)
1511
+
1512
+ if start_date and end_date:
1513
+ interval = TimeInterval(start_date, end_date)
1514
+ type_idx = Leave.Types.get(leave_type, 5) # Default to 'annual' (5)
1515
+ leave = Leave(interval, type_idx)
1516
+
1517
+ # Store leaves as a list for all scenarios
1518
+ for scIdx in range(obj.project.scenarioCount()):
1519
+ existing = obj.get('leaves', scIdx) or []
1520
+ if not isinstance(existing, list):
1521
+ existing = [existing]
1522
+ existing.append(leave)
1523
+ obj[('leaves', scIdx)] = existing
1524
+ elif key == 'limits':
1525
+ # Task or resource limits - create Limits object
1526
+ from scriptplan.core.limits import Limits
1527
+
1528
+ limits_obj = Limits()
1529
+ limits_obj.setProject(obj.project)
1530
+
1531
+ # value is a list of limit dicts from parsing
1532
+ for limit_def in value:
1533
+ limit_type = limit_def.get('type', 'dailymax')
1534
+ limit_value = limit_def.get('value', 0)
1535
+ limit_resources = limit_def.get('resources')
1536
+
1537
+ if limit_resources:
1538
+ # Resource-specific limits
1539
+ for res_id in limit_resources:
1540
+ limits_obj.setLimit(limit_type, limit_value, resource=res_id)
1541
+ else:
1542
+ # General limit
1543
+ limits_obj.setLimit(limit_type, limit_value)
1544
+
1545
+ # Store limits on task/resource for all scenarios
1546
+ # IMPORTANT: Each scenario needs its own copy of the limits
1547
+ # because they track usage counters independently
1548
+ for scIdx in range(obj.project.scenarioCount()):
1549
+ obj[('limits', scIdx)] = limits_obj.copy()
1550
+ elif key == 'workinghours':
1551
+ # Working hours for resource or shift
1552
+ from scriptplan.core.working_hours import WorkingHours
1553
+
1554
+ # Check if working hours already exist
1555
+ existing_wh = obj.get('workinghours', 0)
1556
+ if existing_wh and hasattr(existing_wh, 'set_hours'):
1557
+ wh = existing_wh
1558
+ else:
1559
+ wh = WorkingHours(obj.project)
1560
+
1561
+ if isinstance(value, dict):
1562
+ days = value.get('days', [])
1563
+ ranges = value.get('ranges', [])
1564
+ wh.set_hours(days, ranges)
1565
+
1566
+ # Store working hours for all scenarios
1567
+ for scIdx in range(obj.project.scenarioCount()):
1568
+ obj[('workinghours', scIdx)] = wh
1569
+ elif key == 'workinghours_shift':
1570
+ # Resource references a shift for its working hours
1571
+ # Lookup the shift and copy its working hours
1572
+ shift_id = value
1573
+ shift = obj.project.shifts[shift_id] if hasattr(obj.project, 'shifts') else None
1574
+ if shift:
1575
+ for scIdx in range(obj.project.scenarioCount()):
1576
+ # Store the shift reference - ResourceScenario.onShift will use it
1577
+ obj[('shifts', scIdx)] = shift
1578
+ elif key == 'vacation':
1579
+ # Resource vacation - similar to leaves but type is always vacation
1580
+ from scriptplan.core.leave import Leave
1581
+ from scriptplan.utils.time import TimeInterval
1582
+
1583
+ start_date = value.get('start')
1584
+ end_date = value.get('end', start_date)
1585
+
1586
+ if start_date and end_date:
1587
+ interval = TimeInterval(start_date, end_date)
1588
+ type_idx = Leave.Types.get('annual', 5) # Vacation treated as annual leave
1589
+ leave = Leave(interval, type_idx)
1590
+
1591
+ # Store leaves as a list for all scenarios
1592
+ for scIdx in range(obj.project.scenarioCount()):
1593
+ existing = obj.get('leaves', scIdx) or []
1594
+ if not isinstance(existing, list):
1595
+ existing = [existing]
1596
+ existing.append(leave)
1597
+ obj[('leaves', scIdx)] = existing
1598
+ elif key == 'booking':
1599
+ # Resource booking - blocks resource during a time period
1600
+ # booking "name" date +duration (e.g., "Maintenance" 2025-05-12-09:00 +6h)
1601
+ from scriptplan.core.leave import Leave
1602
+ from scriptplan.utils.time import TimeInterval
1603
+ from datetime import timedelta
1604
+ import re
1605
+
1606
+ start_date = value.get('start')
1607
+ duration_str = value.get('duration', '0h')
1608
+
1609
+ if start_date:
1610
+ # Parse duration to compute end date
1611
+ match = re.match(r'(\d+(?:\.\d+)?)\s*([hdwmymin]+)', str(duration_str))
1612
+ if match:
1613
+ num = float(match.group(1))
1614
+ unit = match.group(2)
1615
+ if unit == 'h':
1616
+ delta = timedelta(hours=num)
1617
+ elif unit == 'min':
1618
+ delta = timedelta(minutes=num)
1619
+ elif unit == 'd':
1620
+ delta = timedelta(days=num)
1621
+ else:
1622
+ delta = timedelta(hours=num)
1623
+ else:
1624
+ delta = timedelta(hours=0)
1625
+
1626
+ end_date = start_date + delta
1627
+ interval = TimeInterval(start_date, end_date)
1628
+ # Use special type to mark as booking (treated as unavailable)
1629
+ type_idx = Leave.Types.get('special', 3)
1630
+ leave = Leave(interval, type_idx)
1631
+
1632
+ # Store as leaves (blocks resource availability)
1633
+ for scIdx in range(obj.project.scenarioCount()):
1634
+ existing = obj.get('leaves', scIdx) or []
1635
+ if not isinstance(existing, list):
1636
+ existing = [existing]
1637
+ existing.append(leave)
1638
+ obj[('leaves', scIdx)] = existing
1639
+ else:
1640
+ try:
1641
+ obj[key] = value
1642
+ except (ValueError, KeyError, AttributeError):
1643
+ pass
1644
+
1645
+ def _get_scenario_index(self, project, scenario_id):
1646
+ """Get the index of a scenario by its ID."""
1647
+ for i, scenario in enumerate(project.scenarios):
1648
+ if scenario.id == scenario_id:
1649
+ return i
1650
+ return None
1651
+
1652
+ def _create_journal_entry(self, task, entry_data):
1653
+ """Create a journal entry for a task.
1654
+
1655
+ Args:
1656
+ task: The Task object this entry belongs to
1657
+ entry_data: Dict with 'date', 'headline', and 'body' keys
1658
+ """
1659
+ from scriptplan.core.journal import JournalEntry, AlertLevel
1660
+
1661
+ journal = task.project.attributes.get('journal')
1662
+ if journal is None:
1663
+ return
1664
+
1665
+ date = entry_data.get('date')
1666
+ headline = entry_data.get('headline', '')
1667
+ body = entry_data.get('body', {})
1668
+
1669
+ # Create the journal entry
1670
+ entry = journal.create_entry(date, headline, task)
1671
+
1672
+ # Set body attributes
1673
+ if body.get('author'):
1674
+ # Look up the author resource
1675
+ author_id = body['author']
1676
+ author = task.project.resources[author_id] if author_id else None
1677
+ entry.author = author
1678
+
1679
+ alert_str = body.get('alert', 'green')
1680
+ if alert_str == 'red':
1681
+ entry.alert_level = AlertLevel.RED
1682
+ elif alert_str == 'yellow':
1683
+ entry.alert_level = AlertLevel.YELLOW
1684
+ else:
1685
+ entry.alert_level = AlertLevel.GREEN
1686
+
1687
+ entry.summary = body.get('summary')
1688
+ entry.details = body.get('details')
1689
+
1690
+ def _create_report(self, project, report_data, parent=None):
1691
+ """Create a Report from parsed data.
1692
+
1693
+ Args:
1694
+ project: The Project object
1695
+ report_data: Dict with 'type', 'id', 'name', 'attributes'
1696
+ parent: Optional parent report for nested reports
1697
+ """
1698
+ from scriptplan.report.report import Report, ReportType, ReportFormat
1699
+
1700
+ report_type = report_data.get('type')
1701
+ r_id = report_data.get('id') or ''
1702
+ r_name = report_data.get('name') or r_id
1703
+
1704
+ # Create the report
1705
+ report = Report(project, r_id, r_name, parent)
1706
+
1707
+ # Set report type
1708
+ if report_type == 'taskreport':
1709
+ report.type_spec = ReportType.TASK_REPORT
1710
+ elif report_type == 'resourcereport':
1711
+ report.type_spec = ReportType.RESOURCE_REPORT
1712
+ elif report_type == 'textreport':
1713
+ report.type_spec = ReportType.TEXT_REPORT
1714
+ elif report_type == 'accountreport':
1715
+ report.type_spec = ReportType.ACCOUNT_REPORT
1716
+
1717
+ # Default to HTML format if not specified
1718
+ default_formats = [ReportFormat.HTML]
1719
+
1720
+ # Process attributes
1721
+ attributes = report_data.get('attributes', [])
1722
+ for attr in attributes:
1723
+ self._apply_report_attribute(report, attr, default_formats)
1724
+
1725
+ # If no formats were set, use defaults
1726
+ if not report.get('formats'):
1727
+ report['formats'] = default_formats
1728
+
1729
+ return report
1730
+
1731
+ def _apply_report_attribute(self, report, attr, default_formats):
1732
+ """Apply a single attribute to a report.
1733
+
1734
+ Args:
1735
+ report: The Report object
1736
+ attr: The attribute (can be tuple, dict, Token, Tree, or string)
1737
+ default_formats: List to accumulate format types
1738
+ """
1739
+ from lark import Token, Tree
1740
+ from scriptplan.report.report import ReportFormat
1741
+
1742
+ if attr is None:
1743
+ return
1744
+
1745
+ if isinstance(attr, dict):
1746
+ # Nested report definition
1747
+ attr_type = attr.get('type')
1748
+ if attr_type in ['taskreport', 'resourcereport', 'textreport', 'accountreport']:
1749
+ self._create_report(report.project, attr, report)
1750
+ return
1751
+
1752
+ if isinstance(attr, tuple):
1753
+ key, value = attr
1754
+ if key == 'columns':
1755
+ # value is list of column specs
1756
+ report['columns'] = value
1757
+ elif key == 'formats':
1758
+ # value is list of format strings
1759
+ formats = []
1760
+ for fmt_str in value:
1761
+ fmt_str = fmt_str.lower()
1762
+ if fmt_str == 'html':
1763
+ formats.append(ReportFormat.HTML)
1764
+ elif fmt_str == 'csv':
1765
+ formats.append(ReportFormat.CSV)
1766
+ elif fmt_str == 'ical':
1767
+ formats.append(ReportFormat.ICAL)
1768
+ elif fmt_str == 'tjp':
1769
+ formats.append(ReportFormat.TJP)
1770
+ elif fmt_str == 'niku':
1771
+ formats.append(ReportFormat.NIKU)
1772
+ report['formats'] = formats
1773
+ elif key == 'sort':
1774
+ report['sort'] = value
1775
+ elif key == 'period':
1776
+ report['period'] = value
1777
+ else:
1778
+ # Generic attribute
1779
+ try:
1780
+ report[key] = value
1781
+ except (ValueError, KeyError, AttributeError):
1782
+ pass
1783
+ return
1784
+
1785
+ if isinstance(attr, Token):
1786
+ # Tokens are typically attribute values that need context
1787
+ # They represent things like scenarios, hideresource, etc.
1788
+ token_type = attr.type
1789
+ token_val = attr.value
1790
+ if token_val.startswith('"') and token_val.endswith('"'):
1791
+ token_val = token_val[1:-1]
1792
+
1793
+ if token_type == 'STRING':
1794
+ # This could be timeformat, title, headline, etc.
1795
+ # Without knowing the context, we can't assign it properly
1796
+ # These are usually preceded by a keyword in the grammar
1797
+ pass
1798
+ elif token_type == 'ID':
1799
+ # Could be scenarios, loadunit, etc.
1800
+ # Common IDs in reports
1801
+ if token_val in ['plan', 'delayed']:
1802
+ # scenarios attribute
1803
+ existing = report.get('scenarios') or []
1804
+ for i, scenario in enumerate(report.project.scenarios):
1805
+ if scenario.id == token_val:
1806
+ existing.append(i)
1807
+ break
1808
+ report['scenarios'] = existing
1809
+ elif token_val in ['days', 'hours', 'weeks', 'months', 'shortauto', 'longauto']:
1810
+ # loadUnit
1811
+ report['loadUnit'] = token_val
1812
+ elif token_type == 'FILTER_EXPR':
1813
+ # Filter expression for hideResource, hideTask, etc.
1814
+ # Store as filter
1815
+ if token_val.startswith('@') or token_val.startswith('~'):
1816
+ # Could be hideResource or hideTask - store both
1817
+ if not report.get('hideResource'):
1818
+ report['hideResource'] = token_val
1819
+ elif not report.get('hideTask'):
1820
+ report['hideTask'] = token_val
1821
+ elif token_type == 'TASK_PATH':
1822
+ # taskRoot
1823
+ report['taskRoot'] = token_val
1824
+ return
1825
+
1826
+ if isinstance(attr, Tree):
1827
+ # Handle Tree objects (shouldn't happen now that we transform them)
1828
+ tree_data = attr.data
1829
+ if tree_data == 'column_list':
1830
+ columns = []
1831
+ for child in attr.children:
1832
+ if isinstance(child, Tree) and child.data == 'column_spec':
1833
+ col_id = None
1834
+ col_opts = {}
1835
+ for cc in child.children:
1836
+ if isinstance(cc, Token) and cc.type == 'ID':
1837
+ col_id = cc.value
1838
+ elif isinstance(cc, Tree) and cc.data == 'column_options':
1839
+ # Parse options
1840
+ pass
1841
+ if col_id:
1842
+ columns.append({'id': col_id, 'options': col_opts})
1843
+ report['columns'] = columns
1844
+ elif tree_data == 'sort_list':
1845
+ sorts = []
1846
+ for child in attr.children:
1847
+ if isinstance(child, Tree) and child.data == 'sort_item':
1848
+ for cc in child.children:
1849
+ if isinstance(cc, Token):
1850
+ sorts.append(cc.value)
1851
+ report['sort'] = sorts
1852
+ return
1853
+
1854
+ if isinstance(attr, str):
1855
+ # Rich text content (header, footer, headline, etc.)
1856
+ # Without context, we try to determine what it is
1857
+ if '----' in attr:
1858
+ report['footer'] = attr
1859
+ elif '====' in attr or '===' in attr:
1860
+ # Contains headlines, could be header
1861
+ if not report.get('header'):
1862
+ report['header'] = attr
1863
+ elif not report.get('headline'):
1864
+ report['headline'] = attr
1865
+ else:
1866
+ # Generic rich text, could be any of header/footer/headline/caption
1867
+ if not report.get('header'):
1868
+ report['header'] = attr
1869
+
1870
+
1871
+ class ProjectFileParser:
1872
+ """Parser for TJP project files."""
1873
+
1874
+ def __init__(self):
1875
+ grammar_path = os.path.join(os.path.dirname(__file__), 'tjp.lark')
1876
+ with open(grammar_path, 'r') as f:
1877
+ self.grammar = f.read()
1878
+ self.parser = Lark(self.grammar, start='start', parser='lalr')
1879
+
1880
+ def parse(self, text, preprocess_macros=True, schedule=True):
1881
+ """Parse TJP text and return a Project object.
1882
+
1883
+ Args:
1884
+ text: The TJP file content
1885
+ preprocess_macros: If True, expand macros before parsing
1886
+ schedule: If True, schedule the project after parsing to compute task dates
1887
+
1888
+ Returns:
1889
+ A Project object
1890
+ """
1891
+ # Preprocess macros
1892
+ if preprocess_macros:
1893
+ text = preprocess_tjp(text)
1894
+
1895
+ tree = self.parser.parse(text)
1896
+ data = TJPTransformer().transform(tree)
1897
+ builder = ModelBuilder()
1898
+ project = builder.build(data)
1899
+
1900
+ # Schedule the project to compute task dates
1901
+ if schedule:
1902
+ project.schedule()
1903
+
1904
+ return project