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,466 @@
1
+ """
2
+ Report - Base class for all report types.
3
+
4
+ This module implements the Report class which holds the fundamental description
5
+ and functionality to turn a scheduled project into user-readable form.
6
+ A report may contain other reports (nested reports).
7
+ """
8
+
9
+ import os
10
+ from typing import TYPE_CHECKING, Optional, List, Any, Dict
11
+ from enum import Enum
12
+ from pathlib import Path
13
+
14
+ from scriptplan.core.property import PropertyTreeNode
15
+ from scriptplan.core.scenario_data import ScenarioData
16
+ from scriptplan.utils.message_handler import MessageHandler
17
+ from scriptplan.report.report_context import ReportContext
18
+ from scriptplan.report.html_generator import (
19
+ build_html_document, get_default_css
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from scriptplan.core.project import Project
24
+
25
+
26
+ class ReportFormat(Enum):
27
+ """Supported report output formats."""
28
+ HTML = 'html'
29
+ CSV = 'csv'
30
+ ICAL = 'ical'
31
+ TJP = 'tjp'
32
+ CTAGS = 'ctags'
33
+ NIKU = 'niku'
34
+ MSPXML = 'mspxml'
35
+
36
+
37
+ class ReportType(Enum):
38
+ """Supported report types."""
39
+ TASK_REPORT = 'taskreport'
40
+ RESOURCE_REPORT = 'resourcereport'
41
+ ACCOUNT_REPORT = 'accountreport'
42
+ TEXT_REPORT = 'textreport'
43
+ TRACE_REPORT = 'tracereport'
44
+ STATUS_SHEET = 'statusSheet'
45
+ TIME_SHEET = 'timeSheet'
46
+ ICAL = 'iCal'
47
+ NIKU = 'niku'
48
+ EXPORT = 'export'
49
+ TAG_FILE = 'tagfile'
50
+
51
+
52
+ class ReportScenario(ScenarioData):
53
+ """
54
+ Dummy class to make the 'flags' attribute work for reports.
55
+ Reports don't have scenario-specific attributes but need this
56
+ for consistent flag handling.
57
+ """
58
+ pass
59
+
60
+
61
+ class Report(PropertyTreeNode, MessageHandler):
62
+ """
63
+ Base class for all reports.
64
+
65
+ The Report class holds the fundamental description and functionality to
66
+ turn the scheduled project into a user readable form. A report may contain
67
+ other reports (nested reports).
68
+
69
+ Attributes:
70
+ type_spec: The type of report (task, resource, text, etc.)
71
+ content: The generated content object (TaskListRE, ResourceListRE, etc.)
72
+ """
73
+
74
+ def __init__(self, project: 'Project', id: str, name: str, parent: Optional['Report'] = None):
75
+ """
76
+ Create a new Report object.
77
+
78
+ Args:
79
+ project: The Project object
80
+ id: Unique identifier for this report
81
+ name: Display name (also used as filename)
82
+ parent: Optional parent report for nested reports
83
+ """
84
+ super().__init__(project.reports, id, name, parent)
85
+
86
+ self._check_filename(name)
87
+ project.addReport(self)
88
+
89
+ # The type specifier must be set for every report
90
+ self.type_spec: Optional[ReportType] = None
91
+
92
+ # The generated content object
93
+ self.content: Optional[Any] = None
94
+
95
+ # Reports need scenario data for flag handling
96
+ scenario_count = project.scenarioCount()
97
+ self.data = [None] * scenario_count
98
+ for i in range(scenario_count):
99
+ self.data[i] = ReportScenario(self, i, self._scenarioAttributes[i])
100
+
101
+ def _check_filename(self, name: str) -> None:
102
+ """
103
+ Validate the filename for the report.
104
+
105
+ Args:
106
+ name: The filename to validate
107
+ """
108
+ if not name:
109
+ return
110
+
111
+ # Check for invalid characters
112
+ invalid_chars = ['<', '>', ':', '"', '|', '?', '*']
113
+ for char in invalid_chars:
114
+ if char in name:
115
+ self.error('invalid_filename',
116
+ f"Report filename '{name}' contains invalid character '{char}'")
117
+
118
+ def generate(self, requested_formats: Optional[List[ReportFormat]] = None) -> int:
119
+ """
120
+ Generate the report in the requested formats.
121
+
122
+ This is where the main action happens. The report defined by all class
123
+ attributes and report elements is generated according to the requested
124
+ output format(s).
125
+
126
+ Args:
127
+ requested_formats: List of formats to generate. If None, uses
128
+ formats specified in report definition.
129
+
130
+ Returns:
131
+ 0 on success, non-zero on error
132
+ """
133
+ # Store current timezone and set report timezone
134
+ # old_timezone = TjTime.setTimeZone(self.get('timezone'))
135
+
136
+ # Generate intermediate format first
137
+ self.generate_intermediate_format()
138
+
139
+ # Determine which formats to generate
140
+ formats = requested_formats or self.get('formats') or []
141
+
142
+ for fmt in formats:
143
+ if not self.name:
144
+ self.error('empty_report_file_name',
145
+ f"Report {self.id} has output formats requested, "
146
+ "but the file name is empty.")
147
+ continue
148
+
149
+ if fmt == ReportFormat.ICAL:
150
+ self._generate_ical()
151
+ elif fmt == ReportFormat.HTML:
152
+ self._generate_html()
153
+ self._copy_auxiliary_files()
154
+ elif fmt == ReportFormat.CSV:
155
+ self._generate_csv()
156
+ elif fmt == ReportFormat.CTAGS:
157
+ self._generate_ctags()
158
+ elif fmt == ReportFormat.NIKU:
159
+ self._generate_niku()
160
+ elif fmt == ReportFormat.TJP:
161
+ self._generate_tjp()
162
+ elif fmt == ReportFormat.MSPXML:
163
+ self._generate_msp_xml()
164
+ else:
165
+ raise ValueError(f"Unknown report output format {fmt}")
166
+
167
+ # Restore timezone
168
+ # TjTime.setTimeZone(old_timezone)
169
+
170
+ return 0
171
+
172
+ def generate_intermediate_format(self) -> None:
173
+ """
174
+ Generate an output format agnostic version.
175
+
176
+ This intermediate format can later be turned into the respective
177
+ output formats (HTML, CSV, etc.).
178
+ """
179
+ # scenarios = self.get('scenarios') or []
180
+ # if not scenarios:
181
+ # self.warning('all_scenarios_disabled',
182
+ # f"The report {self.fullId} has only disabled scenarios. "
183
+ # "The report will possibly be empty.")
184
+
185
+ self.content = None
186
+
187
+ # Import report type classes here to avoid circular imports
188
+ if self.type_spec == ReportType.TASK_REPORT:
189
+ from scriptplan.report.task_report import TaskReport
190
+ self.content = TaskReport(self)
191
+ elif self.type_spec == ReportType.RESOURCE_REPORT:
192
+ from scriptplan.report.resource_report import ResourceReport
193
+ self.content = ResourceReport(self)
194
+ elif self.type_spec == ReportType.TEXT_REPORT:
195
+ from scriptplan.report.text_report import TextReport
196
+ self.content = TextReport(self)
197
+ elif self.type_spec == ReportType.ACCOUNT_REPORT:
198
+ # from scriptplan.report.account_report import AccountReport
199
+ # self.content = AccountReport(self)
200
+ pass
201
+ elif self.type_spec == ReportType.TRACE_REPORT:
202
+ # from scriptplan.report.trace_report import TraceReport
203
+ # self.content = TraceReport(self)
204
+ pass
205
+ elif self.type_spec == ReportType.STATUS_SHEET:
206
+ # from scriptplan.report.status_sheet_report import StatusSheetReport
207
+ # self.content = StatusSheetReport(self)
208
+ pass
209
+ elif self.type_spec == ReportType.TIME_SHEET:
210
+ # from scriptplan.report.time_sheet_report import TimeSheetReport
211
+ # self.content = TimeSheetReport(self)
212
+ pass
213
+ elif self.type_spec == ReportType.ICAL:
214
+ # from scriptplan.report.ical_report import ICalReport
215
+ # self.content = ICalReport(self)
216
+ pass
217
+ elif self.type_spec == ReportType.NIKU:
218
+ # from scriptplan.report.niku_report import NikuReport
219
+ # self.content = NikuReport(self)
220
+ pass
221
+ elif self.type_spec == ReportType.EXPORT:
222
+ # from scriptplan.report.export_report import ExportReport
223
+ # self.content = ExportReport(self)
224
+ pass
225
+ elif self.type_spec == ReportType.TAG_FILE:
226
+ # from scriptplan.report.tag_file import TagFile
227
+ # self.content = TagFile(self)
228
+ pass
229
+ else:
230
+ if self.type_spec:
231
+ raise ValueError(f"Unknown report type: {self.type_spec}")
232
+
233
+ # Generate intermediate format for the content
234
+ if self.content:
235
+ self.content.generate_intermediate_format()
236
+
237
+ def to_html(self) -> Optional[str]:
238
+ """
239
+ Render the content of the report as HTML.
240
+
241
+ Returns:
242
+ HTML string or None if no content
243
+ """
244
+ return self.content.to_html() if self.content else None
245
+
246
+ def to_csv(self) -> Optional[List[List[str]]]:
247
+ """
248
+ Convert the report to CSV format.
249
+
250
+ Returns:
251
+ List of rows, each row being a list of column values
252
+ """
253
+ return self.content.to_csv() if self.content else None
254
+
255
+ def interactive(self) -> bool:
256
+ """
257
+ Check if report should be rendered in interactive version.
258
+
259
+ The top-level report defines the output format and the interactive setting.
260
+
261
+ Returns:
262
+ True if interactive mode, False otherwise
263
+ """
264
+ if self.project.reportContexts:
265
+ top_report = self.project.reportContexts[0].report
266
+ return top_report.get('interactive') or False
267
+ return False
268
+
269
+ def _get_output_path(self, extension: str) -> Path:
270
+ """
271
+ Get the output file path for a given extension.
272
+
273
+ Args:
274
+ extension: File extension (e.g., 'html', 'csv')
275
+
276
+ Returns:
277
+ Full path to output file
278
+ """
279
+ output_dir = self.project.outputDir or './'
280
+ base_name = self.name or self.id
281
+ return Path(output_dir) / f"{base_name}.{extension}"
282
+
283
+ def _generate_html(self) -> None:
284
+ """Generate HTML output."""
285
+ if not self.content:
286
+ return
287
+
288
+ if not hasattr(self.content, 'to_html'):
289
+ self.warning('html_not_supported',
290
+ f"HTML format is not supported for report {self.id} "
291
+ f"of type {self.type_spec}")
292
+ return
293
+
294
+ html_content = self._build_html_document()
295
+ output_path = self._get_output_path('html')
296
+
297
+ os.makedirs(output_path.parent, exist_ok=True)
298
+ with open(output_path, 'w', encoding='utf-8') as f:
299
+ f.write(html_content)
300
+
301
+ def _build_html_document(self) -> str:
302
+ """
303
+ Build complete HTML document.
304
+
305
+ Returns:
306
+ Complete HTML document as string
307
+ """
308
+ title = f"{self.project.name} - {self.get('title') or self.name}"
309
+ body_content = self.content.to_html() if self.content else ""
310
+ if body_content is None:
311
+ body_content = ""
312
+
313
+ # Build navigation for sibling reports
314
+ navigation = self._build_navigation()
315
+
316
+ # Build subtitle from report period
317
+ subtitle = self._build_subtitle()
318
+
319
+ return build_html_document(
320
+ title=title,
321
+ content=body_content,
322
+ project_name=self.project.name,
323
+ subtitle=subtitle,
324
+ navigation=navigation,
325
+ include_css=True,
326
+ footer=True
327
+ )
328
+
329
+ def _build_navigation(self) -> Optional[List[Dict[str, str]]]:
330
+ """
331
+ Build navigation links for sibling reports.
332
+
333
+ Returns:
334
+ List of navigation items or None
335
+ """
336
+ # Get all reports in the project
337
+ reports = list(self.project.reports)
338
+ if not reports or len(reports) <= 1:
339
+ return None
340
+
341
+ navigation = []
342
+ for report in reports:
343
+ # Skip reports without names (they won't have HTML output)
344
+ if not report.name:
345
+ continue
346
+
347
+ nav_item = {
348
+ 'title': report.get('title') or report.name,
349
+ 'url': f'{report.name}.html',
350
+ 'active': report == self
351
+ }
352
+ navigation.append(nav_item)
353
+
354
+ return navigation if len(navigation) > 1 else None
355
+
356
+ def _build_subtitle(self) -> str:
357
+ """
358
+ Build subtitle showing report period or other context.
359
+
360
+ Returns:
361
+ Subtitle string
362
+ """
363
+ parts = []
364
+
365
+ # Add report period if defined
366
+ start = self.get('start')
367
+ end = self.get('end')
368
+ if start and end:
369
+ from scriptplan.report.html_generator import format_date
370
+ parts.append(f"{format_date(start)} - {format_date(end)}")
371
+
372
+ # Add scenario info if multiple scenarios
373
+ scenarios = self.get('scenarios') or []
374
+ if len(scenarios) > 1:
375
+ scenario_names = []
376
+ for scen in scenarios:
377
+ # Handle both scenario names and indices
378
+ if isinstance(scen, int):
379
+ if scen < len(self.project.scenarios):
380
+ scenario_names.append(self.project.scenarios[scen].name)
381
+ elif isinstance(scen, str):
382
+ # Resolve scenario name to get display name
383
+ for proj_scen in self.project.scenarios:
384
+ if proj_scen.id == scen:
385
+ scenario_names.append(proj_scen.name or scen)
386
+ break
387
+ else:
388
+ scenario_names.append(scen) # Use raw name if not found
389
+ if scenario_names:
390
+ parts.append(f"Scenarios: {', '.join(scenario_names)}")
391
+
392
+ return ' | '.join(parts) if parts else ''
393
+
394
+ def _generate_csv(self) -> None:
395
+ """Generate CSV output."""
396
+ if not self.content:
397
+ return
398
+
399
+ if not hasattr(self.content, 'to_csv'):
400
+ self.warning('csv_not_supported',
401
+ f"CSV format is not supported for report {self.id} "
402
+ f"of type {self.type_spec}")
403
+ return
404
+
405
+ csv_data = self.content.to_csv()
406
+ if not csv_data:
407
+ return
408
+
409
+ output_path = self._get_output_path('csv')
410
+ os.makedirs(output_path.parent, exist_ok=True)
411
+
412
+ import csv
413
+ with open(output_path, 'w', newline='', encoding='utf-8') as f:
414
+ writer = csv.writer(f)
415
+ writer.writerows(csv_data)
416
+
417
+ def _generate_ical(self) -> None:
418
+ """Generate iCal output."""
419
+ # To be implemented
420
+ pass
421
+
422
+ def _generate_ctags(self) -> None:
423
+ """Generate ctags output."""
424
+ # To be implemented
425
+ pass
426
+
427
+ def _generate_niku(self) -> None:
428
+ """Generate Niku output."""
429
+ # To be implemented
430
+ pass
431
+
432
+ def _generate_tjp(self) -> None:
433
+ """Generate TJP export output."""
434
+ # To be implemented
435
+ pass
436
+
437
+ def _generate_msp_xml(self) -> None:
438
+ """Generate MS Project XML output."""
439
+ # To be implemented
440
+ pass
441
+
442
+ def _copy_auxiliary_files(self) -> None:
443
+ """Copy CSS and other auxiliary files to output directory."""
444
+ # To be implemented - copy CSS files, icons, etc.
445
+ pass
446
+
447
+ def addReport(self, report: 'Report') -> None:
448
+ """
449
+ Add this report to the project.
450
+ This is called from __init__ to register with project.
451
+
452
+ Note: This method is on Project, not Report. It's here for documentation.
453
+ """
454
+ pass
455
+
456
+
457
+ def add_report_to_project(project: 'Project', report: Report) -> None:
458
+ """
459
+ Helper function to add a report to a project.
460
+
461
+ Args:
462
+ project: The project to add the report to
463
+ report: The report to add
464
+ """
465
+ # The report is already added via PropertySet in __init__
466
+ pass