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,397 @@
1
+ """
2
+ ReportBase - Abstract base class for report content generators.
3
+
4
+ This module provides the ReportBase class which is the abstract base for all
5
+ kinds of report content generators. Derived classes must implement the
6
+ generate_intermediate_format function as well as to_html, to_csv, etc.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import TYPE_CHECKING, Optional, List, Any, Dict
11
+
12
+ if TYPE_CHECKING:
13
+ from scriptplan.report.report import Report
14
+ from scriptplan.core.project import Project
15
+ from scriptplan.core.property import PropertyList
16
+
17
+
18
+ class ReportBase(ABC):
19
+ """
20
+ Abstract base class for all report content generators.
21
+
22
+ This class provides common functionality for filtering property lists
23
+ and generating the intermediate format that can be converted to
24
+ various output formats (HTML, CSV, etc.).
25
+
26
+ Attributes:
27
+ report: Reference to the parent Report object
28
+ project: Reference to the Project object
29
+ """
30
+
31
+ def __init__(self, report: 'Report'):
32
+ """
33
+ Initialize the ReportBase.
34
+
35
+ Args:
36
+ report: The parent Report object
37
+ """
38
+ self.report = report
39
+ self.project = report.project
40
+
41
+ def a(self, attribute: str) -> Any:
42
+ """
43
+ Convenience function to access a report attribute.
44
+
45
+ Args:
46
+ attribute: Name of the attribute to access
47
+
48
+ Returns:
49
+ The attribute value
50
+ """
51
+ return self.report.get(attribute)
52
+
53
+ def get_scenario_indices(self) -> List[int]:
54
+ """
55
+ Get scenario indices from the report's scenarios attribute.
56
+
57
+ The 'scenarios' attribute can contain either scenario names (strings)
58
+ or scenario indices (integers). This method resolves all names to
59
+ their corresponding indices.
60
+
61
+ Returns:
62
+ List of scenario indices (integers). Defaults to [0] if not set.
63
+ """
64
+ scenarios = self.a('scenarios') or []
65
+ if not scenarios:
66
+ return [0]
67
+
68
+ result = []
69
+ for scen in scenarios:
70
+ if isinstance(scen, int):
71
+ result.append(scen)
72
+ elif isinstance(scen, str):
73
+ # Resolve scenario name to index
74
+ for idx, proj_scen in enumerate(self.project.scenarios):
75
+ if proj_scen.id == scen:
76
+ result.append(idx)
77
+ break
78
+ else:
79
+ # Scenario name not found, try to parse as int
80
+ try:
81
+ result.append(int(scen))
82
+ except ValueError:
83
+ # Skip unknown scenarios
84
+ pass
85
+
86
+ return result if result else [0]
87
+
88
+ @abstractmethod
89
+ def generate_intermediate_format(self) -> None:
90
+ """
91
+ Generate the intermediate format representation.
92
+
93
+ This method must be implemented by derived classes to generate
94
+ an output-format-agnostic representation of the report data.
95
+ """
96
+ # Process RichText elements like header, footer, etc.
97
+ query = None
98
+ if self.project.reportContexts:
99
+ query = self.project.reportContexts[-1].query
100
+
101
+ for name in ['header', 'left', 'center', 'right', 'footer',
102
+ 'prolog', 'headline', 'caption', 'epilog']:
103
+ text = self.a(name)
104
+ if text and query and hasattr(text, 'setQuery'):
105
+ text.setQuery(query)
106
+
107
+ @abstractmethod
108
+ def to_html(self) -> Optional[str]:
109
+ """
110
+ Convert the intermediate format to HTML.
111
+
112
+ Returns:
113
+ HTML string representation or None
114
+ """
115
+ pass
116
+
117
+ def to_csv(self) -> Optional[List[List[str]]]:
118
+ """
119
+ Convert the intermediate format to CSV.
120
+
121
+ Returns:
122
+ List of rows (each row is a list of column values)
123
+ """
124
+ return None
125
+
126
+ def filter_account_list(self, account_list: 'PropertyList',
127
+ hide_expr: Any = None,
128
+ rollup_expr: Any = None,
129
+ open_nodes: Optional[List] = None) -> 'PropertyList':
130
+ """
131
+ Filter an account list based on hide/rollup expressions.
132
+
133
+ Takes the complete account list and removes all accounts that are
134
+ matching the hide expression, the rollup expression or are not a
135
+ descendant of accountroot.
136
+
137
+ Args:
138
+ account_list: List of accounts to filter
139
+ hide_expr: Expression to determine which accounts to hide
140
+ rollup_expr: Expression to determine which accounts to roll up
141
+ open_nodes: List of nodes to keep expanded
142
+
143
+ Returns:
144
+ Filtered PropertyList
145
+ """
146
+ from scriptplan.core.property import PropertyList
147
+
148
+ result = PropertyList(account_list)
149
+
150
+ account_root = self.a('accountRoot')
151
+ if account_root:
152
+ # Remove accounts not descended from accountRoot
153
+ result.delete_if(lambda acc: not self._is_child_of(acc, account_root))
154
+
155
+ return self._standard_filter_ops(result, hide_expr, rollup_expr,
156
+ open_nodes, None, account_root)
157
+
158
+ def filter_task_list(self, task_list: 'PropertyList',
159
+ resource: Any = None,
160
+ hide_expr: Any = None,
161
+ rollup_expr: Any = None,
162
+ open_nodes: Optional[List] = None) -> 'PropertyList':
163
+ """
164
+ Filter a task list based on hide/rollup expressions.
165
+
166
+ Takes the complete task list and removes all tasks that are matching
167
+ the hide expression, the rollup expression or are not a descendant
168
+ of taskroot. If resource is not None, a task is only included if
169
+ the resource is allocated to it.
170
+
171
+ Args:
172
+ task_list: List of tasks to filter
173
+ resource: Optional resource filter
174
+ hide_expr: Expression to determine which tasks to hide
175
+ rollup_expr: Expression to determine which tasks to roll up
176
+ open_nodes: List of nodes to keep expanded
177
+
178
+ Returns:
179
+ Filtered PropertyList
180
+ """
181
+ from scriptplan.core.property import PropertyList
182
+
183
+ result = PropertyList(task_list)
184
+
185
+ task_root = self.a('taskRoot')
186
+ if task_root:
187
+ result.delete_if(lambda task: not self._is_child_of(task, task_root))
188
+
189
+ if resource:
190
+ # Filter to tasks that have the resource allocated
191
+ scenario_indices = self.get_scenario_indices()
192
+ start = self.a('start')
193
+ end = self.a('end')
194
+
195
+ def has_resource(task):
196
+ for scenario_idx in scenario_indices:
197
+ if hasattr(task, 'hasResourceAllocated'):
198
+ if task.hasResourceAllocated(scenario_idx, (start, end), resource):
199
+ return True
200
+ return False
201
+
202
+ result.delete_if(lambda task: not has_resource(task))
203
+
204
+ return self._standard_filter_ops(result, hide_expr, rollup_expr,
205
+ open_nodes, resource, task_root)
206
+
207
+ def filter_resource_list(self, resource_list: 'PropertyList',
208
+ task: Any = None,
209
+ hide_expr: Any = None,
210
+ rollup_expr: Any = None,
211
+ open_nodes: Optional[List] = None) -> 'PropertyList':
212
+ """
213
+ Filter a resource list based on hide/rollup expressions.
214
+
215
+ Takes the complete resource list and removes all resources that are
216
+ matching the hide expression, the rollup expression or are not a
217
+ descendant of resourceroot. If task is not None, a resource is only
218
+ included if it is assigned to the task.
219
+
220
+ Args:
221
+ resource_list: List of resources to filter
222
+ task: Optional task filter
223
+ hide_expr: Expression to determine which resources to hide
224
+ rollup_expr: Expression to determine which resources to roll up
225
+ open_nodes: List of nodes to keep expanded
226
+
227
+ Returns:
228
+ Filtered PropertyList
229
+ """
230
+ from scriptplan.core.property import PropertyList
231
+
232
+ result = PropertyList(resource_list)
233
+
234
+ resource_root = self.a('resourceRoot')
235
+ if resource_root:
236
+ result.delete_if(lambda res: not self._is_child_of(res, resource_root))
237
+
238
+ if task:
239
+ # Filter to resources assigned to the task
240
+ scenario_indices = self.get_scenario_indices()
241
+ start = self.a('start')
242
+ end = self.a('end')
243
+
244
+ def is_assigned(resource):
245
+ for scenario_idx in scenario_indices:
246
+ if hasattr(task, 'hasResourceAllocated'):
247
+ if task.hasResourceAllocated(scenario_idx, (start, end), resource):
248
+ return True
249
+ return False
250
+
251
+ result.delete_if(lambda res: not is_assigned(res))
252
+
253
+ return self._standard_filter_ops(result, hide_expr, rollup_expr,
254
+ open_nodes, task, resource_root)
255
+
256
+ def _standard_filter_ops(self, items: 'PropertyList',
257
+ hide_expr: Any,
258
+ rollup_expr: Any,
259
+ open_nodes: Optional[List],
260
+ scope_property: Any,
261
+ root: Any) -> 'PropertyList':
262
+ """
263
+ Apply standard filtering operations to a property list.
264
+
265
+ Args:
266
+ items: The property list to filter
267
+ hide_expr: Expression determining what to hide
268
+ rollup_expr: Expression determining what to roll up
269
+ open_nodes: List of explicitly open nodes
270
+ scope_property: The scope property for queries
271
+ root: The root property
272
+
273
+ Returns:
274
+ Filtered PropertyList
275
+ """
276
+ # Get query copy for evaluating expressions
277
+ query = None
278
+ if self.project.reportContexts:
279
+ query = self.project.reportContexts[-1].query.copy()
280
+ query.scope_property = scope_property
281
+
282
+ # Remove hidden properties
283
+ if hide_expr and query:
284
+ def should_hide(prop):
285
+ query.property = prop
286
+ return self._eval_expression(hide_expr, query)
287
+ items.delete_if(should_hide)
288
+
289
+ # Remove children of rolled-up properties
290
+ if rollup_expr or open_nodes:
291
+ def should_remove_child(prop):
292
+ parent = prop.parent
293
+ while parent:
294
+ query.property = parent if query else None
295
+
296
+ if open_nodes:
297
+ # If open_nodes specified, only listed nodes are unrolled
298
+ if [parent, scope_property] not in open_nodes:
299
+ return True
300
+ elif rollup_expr:
301
+ # Roll up based on expression
302
+ if self._eval_expression(rollup_expr, query):
303
+ return True
304
+
305
+ parent = parent.parent
306
+ return False
307
+
308
+ items.delete_if(should_remove_child)
309
+
310
+ # Re-add parents in tree mode (if applicable)
311
+ if hasattr(items, 'tree_mode') and items.tree_mode():
312
+ parents = []
313
+ for prop in items:
314
+ parent = prop.parent
315
+ while parent:
316
+ if parent not in items and parent not in parents:
317
+ parents.append(parent)
318
+ if parent == root:
319
+ break
320
+ parent = parent.parent
321
+ items.extend(parents)
322
+
323
+ return items
324
+
325
+ def _is_child_of(self, node: Any, parent: Any) -> bool:
326
+ """
327
+ Check if node is a descendant of parent.
328
+
329
+ Args:
330
+ node: The potential child node
331
+ parent: The potential parent node
332
+
333
+ Returns:
334
+ True if node is a descendant of parent
335
+ """
336
+ if hasattr(node, 'isChildOf'):
337
+ return node.isChildOf(parent)
338
+
339
+ # Manual check
340
+ current = node
341
+ while current:
342
+ if current == parent:
343
+ return True
344
+ current = current.parent if hasattr(current, 'parent') else None
345
+ return False
346
+
347
+ def _eval_expression(self, expr: Any, query: Any) -> bool:
348
+ """
349
+ Evaluate a logical expression.
350
+
351
+ Args:
352
+ expr: The expression to evaluate
353
+ query: The query context
354
+
355
+ Returns:
356
+ Boolean result of expression evaluation
357
+ """
358
+ if hasattr(expr, 'eval'):
359
+ return expr.eval(query)
360
+ if callable(expr):
361
+ return expr(query)
362
+ return bool(expr)
363
+
364
+ def _generate_html_table_frame(self) -> str:
365
+ """
366
+ Generate the HTML table frame with headline.
367
+
368
+ Returns:
369
+ HTML string for table frame start
370
+ """
371
+ html = ['<table class="tj_table_frame" cellspacing="1">']
372
+
373
+ # Add headline if present
374
+ headline = self.a('headline')
375
+ if headline:
376
+ headline_html = self._rich_text_to_html(headline)
377
+ html.append('<tr><td>')
378
+ html.append(f'<div class="tj_table_headline">{headline_html}</div>')
379
+ html.append('</td></tr>')
380
+
381
+ return '\n'.join(html)
382
+
383
+ def _rich_text_to_html(self, text: Any) -> str:
384
+ """
385
+ Convert RichText to HTML.
386
+
387
+ Args:
388
+ text: RichText object or string
389
+
390
+ Returns:
391
+ HTML string
392
+ """
393
+ if text is None:
394
+ return ''
395
+ if hasattr(text, 'to_html'):
396
+ return text.to_html()
397
+ return str(text)
@@ -0,0 +1,248 @@
1
+ """
2
+ ReportContext - Manages context and state during report generation.
3
+
4
+ This module provides the ReportContext class which holds settings used during
5
+ report generation. Reports can be nested, so multiple ReportContext objects
6
+ can exist at a time, but there is always one current context accessible via
7
+ Project.reportContexts[-1].
8
+ """
9
+
10
+ from typing import TYPE_CHECKING, Optional, List, Any, Dict
11
+
12
+ if TYPE_CHECKING:
13
+ from scriptplan.core.project import Project
14
+ from scriptplan.core.property import PropertyList
15
+
16
+
17
+ class Query:
18
+ """
19
+ Query object for accessing property attributes during report generation.
20
+
21
+ This class provides a unified interface for querying task/resource attributes
22
+ with proper formatting and scenario handling.
23
+ """
24
+
25
+ def __init__(self, attrs: Optional[Dict[str, Any]] = None):
26
+ self.project = None
27
+ self.property = None
28
+ self.scope_property = None
29
+ self.scenario_idx = None
30
+ self.attributeId = None # Attribute ID to query
31
+ self.result = None # Result of the query
32
+ self.load_unit = 'days'
33
+ self.number_format = None
34
+ self.time_format = '%Y-%m-%d'
35
+ self.currency_format = None
36
+ self.start = None
37
+ self.end = None
38
+ self.hide_journal_entry = None
39
+ self.journal_mode = None
40
+ self.journal_attributes = None
41
+ self.sort_journal_entries = None
42
+ self.cost_account = None
43
+ self.revenue_account = None
44
+
45
+ if attrs:
46
+ for key, value in attrs.items():
47
+ attr_name = self._camel_to_snake(key)
48
+ if hasattr(self, attr_name):
49
+ setattr(self, attr_name, value)
50
+
51
+ def _camel_to_snake(self, name: str) -> str:
52
+ """Convert camelCase to snake_case."""
53
+ import re
54
+ s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
55
+ return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
56
+
57
+ def copy(self) -> 'Query':
58
+ """Create a copy of this Query."""
59
+ new_query = Query()
60
+ new_query.project = self.project
61
+ new_query.property = self.property
62
+ new_query.scope_property = self.scope_property
63
+ new_query.scenario_idx = self.scenario_idx
64
+ new_query.attributeId = self.attributeId
65
+ new_query.result = self.result
66
+ new_query.load_unit = self.load_unit
67
+ new_query.number_format = self.number_format
68
+ new_query.time_format = self.time_format
69
+ new_query.currency_format = self.currency_format
70
+ new_query.start = self.start
71
+ new_query.end = self.end
72
+ new_query.hide_journal_entry = self.hide_journal_entry
73
+ new_query.journal_mode = self.journal_mode
74
+ new_query.journal_attributes = self.journal_attributes
75
+ new_query.sort_journal_entries = self.sort_journal_entries
76
+ new_query.cost_account = self.cost_account
77
+ new_query.revenue_account = self.revenue_account
78
+ return new_query
79
+
80
+ def process(self) -> Any:
81
+ """
82
+ Process the query and return the result.
83
+
84
+ This is the main entry point for executing a query against a property.
85
+ """
86
+ if not self.property:
87
+ self.result = None
88
+ return None
89
+
90
+ # If attributeId is set, fetch the value from the property
91
+ if self.attributeId:
92
+ try:
93
+ # Use scenario_idx if available, otherwise just get the attribute
94
+ if self.scenario_idx is not None:
95
+ # Ensure property supports scenario indexing
96
+ # We pass scenario_idx as a tuple key if the property supports it
97
+ # But property.get() usually takes (name, scIdx) or just name
98
+ # Let's look at how PropertyTreeNode.get is implemented
99
+ # It usually takes (attribute_name, scenario_idx)
100
+
101
+ # Note: property.py logic sets self.scenarioIdx which maps to self.scenario_idx here
102
+ # property.py calls self._query.process()
103
+
104
+ val = self.property.get(self.attributeId, self.scenario_idx)
105
+ else:
106
+ val = self.property.get(self.attributeId)
107
+
108
+ self.result = val
109
+ except Exception:
110
+ self.result = None
111
+
112
+ return self.result
113
+
114
+ def to_sort(self) -> Any:
115
+ """
116
+ Return a sortable representation of the query result.
117
+ """
118
+ return self.result
119
+
120
+
121
+ class ReportContext:
122
+ """
123
+ Context object for report generation.
124
+
125
+ The ReportContext holds settings and state used during report generation.
126
+ Reports can be nested, creating a stack of contexts. The current context
127
+ is always accessible via Project.reportContexts[-1].
128
+
129
+ Attributes:
130
+ project: Reference to the Project object
131
+ report: Reference to the Report being generated
132
+ query: Query object for attribute access
133
+ tasks: List of tasks in scope for this report
134
+ resources: List of resources in scope for this report
135
+ dynamic_report_id: Unique identifier for nested reports
136
+ child_report_counter: Counter for generating child report IDs
137
+ attribute_backup: Backup of modified attributes for restoration
138
+ """
139
+
140
+ def __init__(self, project: 'Project', report: Any):
141
+ """
142
+ Initialize a new ReportContext.
143
+
144
+ Args:
145
+ project: The Project object
146
+ report: The Report object being generated
147
+ """
148
+ self.project = project
149
+ self.report = report
150
+ self.child_report_counter = 0
151
+ self.attribute_backup = None
152
+
153
+ # Build query attributes from report settings
154
+ query_attrs = {
155
+ 'project': self.project,
156
+ 'loadUnit': self._get_report_attr('loadUnit', 'days'),
157
+ 'numberFormat': self._get_report_attr('numberFormat'),
158
+ 'timeFormat': self._get_report_attr('timeFormat', '%Y-%m-%d'),
159
+ 'currencyFormat': self._get_report_attr('currencyFormat'),
160
+ 'start': self._get_report_attr('start'),
161
+ 'end': self._get_report_attr('end'),
162
+ 'hideJournalEntry': self._get_report_attr('hideJournalEntry'),
163
+ 'journalMode': self._get_report_attr('journalMode'),
164
+ 'journalAttributes': self._get_report_attr('journalAttributes'),
165
+ 'sortJournalEntries': self._get_report_attr('sortJournalEntries'),
166
+ 'costAccount': self._get_report_attr('costaccount'),
167
+ 'revenueAccount': self._get_report_attr('revenueaccount'),
168
+ }
169
+ self.query = Query(query_attrs)
170
+
171
+ # Get parent context if exists
172
+ parent = project.reportContexts[-1] if project.reportContexts else None
173
+
174
+ if parent:
175
+ # For interactive/nested reports, generate a unique ID based on
176
+ # parent's ID and child counter
177
+ self.dynamic_report_id = f"{parent.dynamic_report_id}.{parent.child_report_counter}"
178
+ parent.child_report_counter += 1
179
+
180
+ # Inherit task and resource lists from parent
181
+ self.tasks = list(parent.tasks) if parent.tasks else []
182
+ self.resources = list(parent.resources) if parent.resources else []
183
+ else:
184
+ # Root context - ID is "0", get all tasks/resources from project
185
+ self.dynamic_report_id = "0"
186
+ self.tasks = list(project.tasks) if project.tasks else []
187
+ self.resources = list(project.resources) if project.resources else []
188
+
189
+ def _get_report_attr(self, attr_name: str, default: Any = None) -> Any:
190
+ """
191
+ Get an attribute from the report.
192
+
193
+ Args:
194
+ attr_name: Name of the attribute
195
+ default: Default value if attribute not found
196
+
197
+ Returns:
198
+ The attribute value or default
199
+ """
200
+ try:
201
+ if hasattr(self.report, 'get'):
202
+ val = self.report.get(attr_name)
203
+ return val if val is not None else default
204
+ except (ValueError, KeyError, AttributeError):
205
+ pass
206
+ return default
207
+
208
+ def push(self) -> 'ReportContext':
209
+ """
210
+ Push this context onto the project's context stack.
211
+
212
+ Returns:
213
+ self for chaining
214
+ """
215
+ self.project.reportContexts.append(self)
216
+ return self
217
+
218
+ def pop(self) -> 'ReportContext':
219
+ """
220
+ Pop this context from the project's context stack.
221
+
222
+ Returns:
223
+ self for chaining
224
+ """
225
+ if self.project.reportContexts and self.project.reportContexts[-1] is self:
226
+ self.project.reportContexts.pop()
227
+ return self
228
+
229
+ def backup_attributes(self, property_node: Any) -> None:
230
+ """
231
+ Backup attributes from a property node for later restoration.
232
+
233
+ Args:
234
+ property_node: The property node whose attributes to backup
235
+ """
236
+ if hasattr(property_node, 'backupAttributes'):
237
+ self.attribute_backup = property_node.backupAttributes()
238
+
239
+ def restore_attributes(self, property_node: Any) -> None:
240
+ """
241
+ Restore previously backed up attributes to a property node.
242
+
243
+ Args:
244
+ property_node: The property node to restore attributes to
245
+ """
246
+ if self.attribute_backup and hasattr(property_node, 'restoreAttributes'):
247
+ property_node.restoreAttributes(self.attribute_backup)
248
+ self.attribute_backup = None