poelis-sdk 0.5.4__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.

Potentially problematic release.


This version of poelis-sdk might be problematic. Click here for more details.

@@ -0,0 +1,769 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import warnings
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ """Property change tracking for Poelis SDK.
12
+
13
+ This module provides utilities to track property values and warn users
14
+ when values change between script/notebook runs.
15
+ """
16
+
17
+
18
+ class PropertyValueChangedWarning(UserWarning):
19
+ """Warning emitted when a property value has changed since first access."""
20
+
21
+ pass
22
+
23
+
24
+ class ItemOrPropertyDeletedWarning(UserWarning):
25
+ """Warning emitted when an item or property that was previously accessed has been deleted."""
26
+
27
+ pass
28
+
29
+
30
+ class PropertyChangeTracker:
31
+ """Tracks property values and detects changes.
32
+
33
+ Records baseline values when properties are first accessed, and compares
34
+ subsequent accesses to detect changes. Emits warnings when changes are detected.
35
+ Supports persistent storage via JSON files and change logging.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ enabled: bool = False,
41
+ baseline_file: Optional[str] = None,
42
+ log_file: Optional[str] = None,
43
+ ) -> None:
44
+ """Initialize the change tracker.
45
+
46
+ Args:
47
+ enabled: Whether change detection is enabled.
48
+ baseline_file: Optional path to JSON file for persistent baseline storage.
49
+ If None, baselines are only stored in memory. Defaults to None.
50
+ log_file: Optional path to log file for recording changes.
51
+ If None, changes are only logged via warnings. Defaults to None.
52
+ """
53
+ self._enabled = enabled
54
+ # Key: property_id, Value: {value, name, first_accessed_at, updated_at, updated_by}
55
+ self._baselines: Dict[str, Dict[str, Any]] = {}
56
+ self._accessed_items: Dict[str, Dict[str, Any]] = {}
57
+ # Key: item_path (e.g., "workspace.product.item"), Value: {name, first_accessed_at, item_id}
58
+ self._accessed_properties: Dict[str, Dict[str, Any]] = {}
59
+ # Key: property_path (e.g., "workspace.product.item.property"), Value: {name, property_id, first_accessed_at}
60
+ self._baseline_file: Optional[str] = baseline_file
61
+ self._log_file: Optional[str] = log_file
62
+ self._changes_this_session: List[Dict[str, Any]] = []
63
+ self._deletions_this_session: List[Dict[str, Any]] = []
64
+
65
+ # Load baselines from file if it exists
66
+ if self._baseline_file and os.path.exists(self._baseline_file):
67
+ self._load_baselines()
68
+
69
+ def is_enabled(self) -> bool:
70
+ """Check if change detection is enabled.
71
+
72
+ Returns:
73
+ bool: True if change detection is enabled, False otherwise.
74
+ """
75
+ return self._enabled
76
+
77
+ def enable(self) -> None:
78
+ """Enable change detection."""
79
+ self._enabled = True
80
+
81
+ def disable(self) -> None:
82
+ """Disable change detection."""
83
+ self._enabled = False
84
+
85
+ def clear_baselines(self) -> None:
86
+ """Clear all recorded baseline values."""
87
+ self._baselines.clear()
88
+ self._accessed_items.clear()
89
+ self._accessed_properties.clear()
90
+ # Also delete the baseline file if it exists
91
+ if self._baseline_file:
92
+ baseline_path = Path(self._baseline_file)
93
+ if not baseline_path.is_absolute():
94
+ baseline_path = Path.cwd() / baseline_path
95
+ if baseline_path.exists():
96
+ try:
97
+ baseline_path.unlink()
98
+ except Exception:
99
+ pass # Silently ignore errors
100
+
101
+ def record_baseline(
102
+ self,
103
+ property_id: str,
104
+ value: Any,
105
+ name: Optional[str] = None,
106
+ updated_at: Optional[str] = None,
107
+ updated_by: Optional[str] = None,
108
+ ) -> None:
109
+ """Record a baseline value for a property.
110
+
111
+ If the property has already been recorded, this does nothing (first access wins).
112
+
113
+ Args:
114
+ property_id: Unique identifier for the property.
115
+ value: The property value to record as baseline.
116
+ name: Optional display name for the property.
117
+ updated_at: Optional ISO 8601 timestamp when the property was last updated.
118
+ updated_by: Optional user ID of the last updater.
119
+ """
120
+ if not self._enabled:
121
+ return
122
+
123
+ if property_id not in self._baselines:
124
+ self._baselines[property_id] = {
125
+ "value": self._normalize_value(value),
126
+ "name": name or property_id,
127
+ "first_accessed_at": time.time(),
128
+ "updated_at": updated_at,
129
+ "updated_by": updated_by,
130
+ }
131
+ # Save baselines to file if persistent storage is enabled
132
+ if self._baseline_file:
133
+ self._save_baselines()
134
+
135
+ def check_changed(
136
+ self,
137
+ property_id: str,
138
+ current_value: Any,
139
+ name: Optional[str] = None,
140
+ updated_at: Optional[str] = None,
141
+ updated_by: Optional[str] = None,
142
+ ) -> Optional[Dict[str, Any]]:
143
+ """Check if a property value has changed and return change info if so.
144
+
145
+ Args:
146
+ property_id: Unique identifier for the property.
147
+ current_value: The current value to compare against baseline.
148
+ name: Optional display name for the property (used if not in baseline).
149
+ updated_at: Optional ISO 8601 timestamp when the property was last updated.
150
+ updated_by: Optional user ID of the last updater.
151
+
152
+ Returns:
153
+ Optional[Dict[str, Any]]: Change information if changed, None otherwise.
154
+ Contains: old_value, new_value, name, first_accessed_at, time_since_first_access,
155
+ updated_at, updated_by.
156
+ """
157
+ if not self._enabled:
158
+ return None
159
+
160
+ if property_id not in self._baselines:
161
+ # First access - record as baseline
162
+ self.record_baseline(property_id, current_value, name, updated_at, updated_by)
163
+ return None
164
+
165
+ baseline = self._baselines[property_id]
166
+ normalized_current = self._normalize_value(current_value)
167
+ normalized_baseline = baseline["value"]
168
+
169
+ if not self._values_equal(normalized_current, normalized_baseline):
170
+ # Value has changed
171
+ first_accessed_at = baseline["first_accessed_at"]
172
+ time_since = time.time() - first_accessed_at
173
+ change_info = {
174
+ "property_id": property_id,
175
+ "old_value": baseline["value"],
176
+ "new_value": normalized_current,
177
+ "name": baseline.get("name") or name or property_id,
178
+ "first_accessed_at": first_accessed_at,
179
+ "time_since_first_access": time_since,
180
+ "detected_at": time.time(),
181
+ "updated_at": updated_at, # When it was changed in webapp
182
+ "updated_by": updated_by, # Who changed it
183
+ }
184
+ # Record change for logging
185
+ self._changes_this_session.append(change_info)
186
+ # Update baseline to new value for next run comparison
187
+ self._baselines[property_id]["value"] = normalized_current
188
+ self._baselines[property_id]["first_accessed_at"] = time.time() # Reset timestamp
189
+ self._baselines[property_id]["updated_at"] = updated_at
190
+ self._baselines[property_id]["updated_by"] = updated_by
191
+ if self._baseline_file:
192
+ self._save_baselines()
193
+ return change_info
194
+
195
+ return None
196
+
197
+ def get_changed_properties(self) -> Dict[str, Dict[str, Any]]:
198
+ """Get information about all properties that have changed.
199
+
200
+ Note: This only returns properties that have been checked at least twice.
201
+ Properties that were recorded but never checked again won't appear here.
202
+
203
+ Returns:
204
+ Dict[str, Dict[str, Any]]: Dictionary mapping property_id to change info.
205
+ """
206
+ # This would require tracking which properties were checked, which we don't do yet.
207
+ # For now, return empty dict. Can be enhanced later if needed.
208
+ return {}
209
+
210
+ def _normalize_value(self, value: Any) -> Any:
211
+ """Normalize a value for comparison.
212
+
213
+ Handles type conversions that should be considered equal (e.g., int vs float).
214
+
215
+ Args:
216
+ value: The value to normalize.
217
+
218
+ Returns:
219
+ Any: Normalized value.
220
+ """
221
+ # Handle None
222
+ if value is None:
223
+ return None
224
+
225
+ # Handle numeric types - convert to float for comparison
226
+ if isinstance(value, (int, float)):
227
+ return float(value)
228
+
229
+ # Handle lists/arrays - normalize recursively
230
+ if isinstance(value, list):
231
+ return [self._normalize_value(item) for item in value]
232
+
233
+ # For other types (strings, etc.), return as-is
234
+ return value
235
+
236
+ def _values_equal(self, value1: Any, value2: Any) -> bool:
237
+ """Check if two normalized values are equal.
238
+
239
+ Args:
240
+ value1: First value to compare.
241
+ value2: Second value to compare.
242
+
243
+ Returns:
244
+ bool: True if values are equal, False otherwise.
245
+ """
246
+ # Handle None
247
+ if value1 is None and value2 is None:
248
+ return True
249
+ if value1 is None or value2 is None:
250
+ return False
251
+
252
+ # Handle lists/arrays
253
+ if isinstance(value1, list) and isinstance(value2, list):
254
+ if len(value1) != len(value2):
255
+ return False
256
+ return all(self._values_equal(v1, v2) for v1, v2 in zip(value1, value2))
257
+
258
+ # For numeric types, use approximate equality for floats
259
+ if isinstance(value1, float) and isinstance(value2, float):
260
+ # Use a small epsilon for float comparison
261
+ return abs(value1 - value2) < 1e-10
262
+
263
+ # For other types, use standard equality
264
+ return value1 == value2
265
+
266
+ def _format_time_delta(self, seconds: float) -> str:
267
+ """Format a time delta in a human-readable way.
268
+
269
+ Args:
270
+ seconds: Time delta in seconds.
271
+
272
+ Returns:
273
+ str: Human-readable time delta (e.g., "2 days ago", "3 hours ago").
274
+ """
275
+ if seconds < 60:
276
+ return f"{int(seconds)} seconds ago"
277
+ elif seconds < 3600:
278
+ minutes = int(seconds / 60)
279
+ return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
280
+ elif seconds < 86400:
281
+ hours = int(seconds / 3600)
282
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
283
+ else:
284
+ days = int(seconds / 86400)
285
+ return f"{days} day{'s' if days != 1 else ''} ago"
286
+
287
+ def warn_if_changed(
288
+ self,
289
+ property_id: str,
290
+ current_value: Any,
291
+ name: Optional[str] = None,
292
+ updated_at: Optional[str] = None,
293
+ updated_by: Optional[str] = None,
294
+ ) -> None:
295
+ """Check for changes and emit a warning if the value has changed.
296
+
297
+ Args:
298
+ property_id: Unique identifier for the property.
299
+ current_value: The current value to check.
300
+ name: Optional display name for the property.
301
+ updated_at: Optional ISO 8601 timestamp when the property was last updated.
302
+ updated_by: Optional user ID of the last updater.
303
+ """
304
+ if not self._enabled:
305
+ return
306
+
307
+ change_info = self.check_changed(property_id, current_value, name, updated_at, updated_by)
308
+ if change_info:
309
+ # Format warning message with update info
310
+ message_parts = [
311
+ f"Property '{change_info['name']}' changed: "
312
+ f"{change_info['old_value']} → {change_info['new_value']}"
313
+ ]
314
+
315
+ # Add updated_by and updated_at if available
316
+ if change_info.get("updated_by"):
317
+ message_parts.append(f"(updated by {change_info['updated_by']}")
318
+ if change_info.get("updated_at"):
319
+ # Format the ISO timestamp to be more readable
320
+ try:
321
+ dt = datetime.fromisoformat(change_info["updated_at"].replace("Z", "+00:00"))
322
+ formatted_time = dt.strftime("%Y-%m-%d %H:%M:%S")
323
+ message_parts.append(f"at {formatted_time})")
324
+ except Exception:
325
+ message_parts.append(f"at {change_info['updated_at']}")
326
+
327
+ # Fallback to time since first access if updated_at not available
328
+ if not change_info.get("updated_at"):
329
+ time_str = self._format_time_delta(change_info["time_since_first_access"])
330
+ message_parts.append(f"(first accessed {time_str})")
331
+
332
+ # Hint user to check the change log file (with resolved path) if configured
333
+ if self._log_file:
334
+ try:
335
+ log_path = Path(self._log_file)
336
+ if not log_path.is_absolute():
337
+ log_path = Path.cwd() / log_path
338
+ # Show a short path like `/poelis-python-sdk/poelis_changes.log`
339
+ project_dir = log_path.parent.name
340
+ display_path = f"/{project_dir}/{log_path.name}"
341
+ message_parts.append(f"[see change log: {display_path}]")
342
+ except Exception:
343
+ # If path resolution fails, skip the hint rather than breaking the warning
344
+ pass
345
+
346
+ message = " ".join(message_parts)
347
+
348
+ # Use a custom format that only shows the message (no file path/line number)
349
+ original_format = warnings.formatwarning
350
+ def simple_format(message, category, filename, lineno, line=None):
351
+ return f"{category.__name__}: {message}\n"
352
+
353
+ warnings.formatwarning = simple_format
354
+ try:
355
+ warnings.warn(message, PropertyValueChangedWarning, stacklevel=3)
356
+ finally:
357
+ warnings.formatwarning = original_format
358
+
359
+ # Log to file if enabled
360
+ if self._log_file:
361
+ self._log_change(change_info)
362
+
363
+ def write_change_log(self) -> None:
364
+ """Write all changes detected in this session to the log file.
365
+
366
+ This should be called at the end of a script run to ensure all changes
367
+ are logged, even if warnings were suppressed.
368
+ """
369
+ if not self._log_file or not self._changes_this_session:
370
+ return
371
+
372
+ # Resolve log file path (relative paths are relative to current working directory)
373
+ log_path = Path(self._log_file)
374
+ # If it's a relative path, resolve it relative to current working directory
375
+ if not log_path.is_absolute():
376
+ log_path = Path.cwd() / log_path
377
+
378
+ # Ensure log directory exists
379
+ log_path.parent.mkdir(parents=True, exist_ok=True)
380
+
381
+ # Append to log file
382
+ with open(log_path, "a", encoding="utf-8") as f:
383
+ timestamp = datetime.now().isoformat()
384
+ f.write(f"\n{'='*80}\n")
385
+ f.write(f"Session: {timestamp}\n")
386
+ f.write(f"{'='*80}\n")
387
+
388
+ if self._changes_this_session:
389
+ f.write(f"Detected {len(self._changes_this_session)} property change(s):\n\n")
390
+ for change in self._changes_this_session:
391
+ f.write(f" Property: {change['name']} (ID: {change['property_id']})\n")
392
+ f.write(f" Old value: {change['old_value']}\n")
393
+ f.write(f" New value: {change['new_value']}\n")
394
+ if change.get("updated_by"):
395
+ f.write(f" Updated by: {change['updated_by']}\n")
396
+ if change.get("updated_at"):
397
+ f.write(f" Updated at: {change['updated_at']}\n")
398
+ time_str = self._format_time_delta(change["time_since_first_access"])
399
+ f.write(f" First accessed: {time_str}\n")
400
+ f.write(f" Detected at: {datetime.fromtimestamp(change['detected_at']).isoformat()}\n")
401
+ f.write("\n")
402
+ if self._deletions_this_session:
403
+ f.write(f"\nDetected {len(self._deletions_this_session)} deletion(s):\n\n")
404
+ for deletion in self._deletions_this_session:
405
+ f.write(f" {deletion['type'].title()}: {deletion['name']} (Path: {deletion['path']})\n")
406
+ f.write(f" Detected at: {datetime.fromtimestamp(deletion['detected_at']).isoformat()}\n")
407
+ f.write("\n")
408
+
409
+ if not self._changes_this_session and not self._deletions_this_session:
410
+ f.write("No property changes or deletions detected.\n")
411
+
412
+ def record_accessed_item(self, item_path: str, item_name: str, item_id: Optional[str] = None) -> None:
413
+ """Record that an item was accessed.
414
+
415
+ Args:
416
+ item_path: Path to the item (e.g., "workspace.product.item").
417
+ item_name: Display name of the item.
418
+ item_id: Optional item ID.
419
+ """
420
+ if not self._enabled:
421
+ return
422
+
423
+ if item_path not in self._accessed_items:
424
+ self._accessed_items[item_path] = {
425
+ "name": item_name,
426
+ "item_id": item_id,
427
+ "first_accessed_at": time.time(),
428
+ }
429
+ if self._baseline_file:
430
+ self._save_baselines()
431
+
432
+ def record_accessed_property(self, property_path: str, property_name: str, property_id: Optional[str] = None) -> None:
433
+ """Record that a property was accessed.
434
+
435
+ Args:
436
+ property_path: Path to the property (e.g., "workspace.product.item.property").
437
+ property_name: Display name of the property.
438
+ property_id: Optional property ID.
439
+ """
440
+ if not self._enabled:
441
+ return
442
+
443
+ now = time.time()
444
+ if property_path not in self._accessed_properties:
445
+ self._accessed_properties[property_path] = {
446
+ "name": property_name,
447
+ "property_id": property_id,
448
+ "first_accessed_at": now,
449
+ }
450
+ else:
451
+ # Keep metadata reasonably fresh if the same path is accessed again.
452
+ # Do not overwrite an existing property_id with None.
453
+ existing = self._accessed_properties[property_path]
454
+ if property_id is not None and existing.get("property_id") is None:
455
+ existing["property_id"] = property_id
456
+ # Always keep the earliest first_accessed_at so time deltas remain meaningful.
457
+ if "first_accessed_at" not in existing:
458
+ existing["first_accessed_at"] = now
459
+
460
+ # Save baselines to file if persistent storage is enabled
461
+ if self._baseline_file:
462
+ self._save_baselines()
463
+
464
+ def check_item_deleted(self, item_path: str) -> Optional[Dict[str, Any]]:
465
+ """Check if an item that was previously accessed has been deleted.
466
+
467
+ Args:
468
+ item_path: Path to the item (e.g., "workspace.product.item").
469
+
470
+ Returns:
471
+ Optional[Dict[str, Any]]: Deletion information if the item was deleted, None otherwise.
472
+ """
473
+ if not self._enabled:
474
+ return None
475
+
476
+ if item_path in self._accessed_items:
477
+ # Item was accessed before but doesn't exist now - it was deleted
478
+ item_info = self._accessed_items[item_path]
479
+ deletion_info = {
480
+ "type": "item",
481
+ "path": item_path,
482
+ "name": item_info.get("name", item_path),
483
+ "item_id": item_info.get("item_id"),
484
+ "first_accessed_at": item_info.get("first_accessed_at", time.time()),
485
+ "detected_at": time.time(),
486
+ }
487
+ self._deletions_this_session.append(deletion_info)
488
+ # Remove from accessed_items since it's been deleted
489
+ del self._accessed_items[item_path]
490
+ if self._baseline_file:
491
+ self._save_baselines()
492
+ return deletion_info
493
+
494
+ # Fallback: Check if any property path contains this item path
495
+ # This handles cases where the item was accessed via a property but not tracked as an item
496
+ for prop_path, prop_info in list(self._accessed_properties.items()):
497
+ # Check if property path starts with item_path (e.g., "workspace.product.item.property" starts with "workspace.product.item")
498
+ if prop_path.startswith(item_path + "."):
499
+ # Extract item name from property path or use the last part of item_path
500
+ item_name = item_path.split(".")[-1] if "." in item_path else item_path
501
+ deletion_info = {
502
+ "type": "item",
503
+ "path": item_path,
504
+ "name": item_name,
505
+ "item_id": None,
506
+ "first_accessed_at": prop_info.get("first_accessed_at", time.time()),
507
+ "detected_at": time.time(),
508
+ }
509
+ self._deletions_this_session.append(deletion_info)
510
+ # Remove all properties under this item path
511
+ properties_to_remove = [
512
+ p for p in self._accessed_properties.keys()
513
+ if p.startswith(item_path + ".")
514
+ ]
515
+ for prop_to_remove in properties_to_remove:
516
+ del self._accessed_properties[prop_to_remove]
517
+ if self._baseline_file:
518
+ self._save_baselines()
519
+ return deletion_info
520
+
521
+ return None
522
+
523
+ def check_property_deleted(self, property_path: str) -> Optional[Dict[str, Any]]:
524
+ """Check if a property that was previously accessed has been deleted.
525
+
526
+ Args:
527
+ property_path: Path to the property (e.g., "workspace.product.item.property").
528
+
529
+ Returns:
530
+ Optional[Dict[str, Any]]: Deletion information if the property was deleted, None otherwise.
531
+ """
532
+ if not self._enabled:
533
+ return None
534
+
535
+ if property_path in self._accessed_properties:
536
+ # Property was accessed before but doesn't exist now - it was deleted
537
+ prop_info = self._accessed_properties[property_path]
538
+ deletion_info = {
539
+ "type": "property",
540
+ "path": property_path,
541
+ "name": prop_info.get("name", property_path),
542
+ "property_id": prop_info.get("property_id"),
543
+ "first_accessed_at": prop_info.get("first_accessed_at", time.time()),
544
+ "detected_at": time.time(),
545
+ }
546
+ self._deletions_this_session.append(deletion_info)
547
+ # Remove from accessed_properties since it's been deleted
548
+ del self._accessed_properties[property_path]
549
+ # Also remove from baselines if it exists
550
+ if deletion_info.get("property_id") and deletion_info["property_id"] in self._baselines:
551
+ del self._baselines[deletion_info["property_id"]]
552
+ if self._baseline_file:
553
+ self._save_baselines()
554
+ return deletion_info
555
+
556
+ return None
557
+
558
+ def warn_if_deleted(self, item_path: Optional[str] = None, property_path: Optional[str] = None) -> None:
559
+ """Check for deletions and emit warnings if items or properties have been deleted.
560
+
561
+ Args:
562
+ item_path: Optional path to an item that doesn't exist.
563
+ property_path: Optional path to a property that doesn't exist.
564
+ """
565
+ if not self._enabled:
566
+ return
567
+
568
+ deletion_info = None
569
+ if property_path:
570
+ deletion_info = self.check_property_deleted(property_path)
571
+ elif item_path:
572
+ deletion_info = self.check_item_deleted(item_path)
573
+
574
+ if deletion_info:
575
+ # Format warning message
576
+ message = (
577
+ f"{deletion_info['type'].title()} '{deletion_info['name']}' has been deleted or moved"
578
+ )
579
+
580
+ # Add log file hint if available
581
+ if self._log_file:
582
+ try:
583
+ log_path = Path(self._log_file)
584
+ if not log_path.is_absolute():
585
+ log_path = Path.cwd() / log_path
586
+ # Show path from project root
587
+ try:
588
+ cwd_parts = Path.cwd().parts
589
+ log_parts = log_path.parts
590
+ # Find common prefix and show relative path
591
+ common_len = 0
592
+ for i in range(min(len(cwd_parts), len(log_parts))):
593
+ if cwd_parts[i] == log_parts[i]:
594
+ common_len = i + 1
595
+ else:
596
+ break
597
+ if common_len > 0:
598
+ rel_parts = log_parts[common_len:]
599
+ if rel_parts:
600
+ display_path = "/" + "/".join(rel_parts)
601
+ else:
602
+ display_path = str(log_path.name)
603
+ else:
604
+ display_path = str(log_path)
605
+ except Exception:
606
+ display_path = str(log_path)
607
+ message += f" [see change log: {display_path}]"
608
+ except Exception:
609
+ pass # Skip log path hint if it fails
610
+
611
+ # Use a custom format that only shows the message (no file path/line number)
612
+ original_format = warnings.formatwarning
613
+ def simple_format(message, category, filename, lineno, line=None):
614
+ return f"{category.__name__}: {message}\n"
615
+
616
+ warnings.formatwarning = simple_format
617
+ try:
618
+ warnings.warn(message, ItemOrPropertyDeletedWarning, stacklevel=4)
619
+ finally:
620
+ warnings.formatwarning = original_format
621
+
622
+ # Log to file if enabled
623
+ if self._log_file:
624
+ self._log_deletion(deletion_info)
625
+
626
+ def _log_deletion(self, deletion_info: Dict[str, Any]) -> None:
627
+ """Log a deletion to the log file.
628
+
629
+ Args:
630
+ deletion_info: Dictionary containing deletion information.
631
+ """
632
+ if not self._log_file:
633
+ return
634
+
635
+ try:
636
+ # Resolve log file path
637
+ log_path = Path(self._log_file)
638
+ if not log_path.is_absolute():
639
+ log_path = Path.cwd() / log_path
640
+
641
+ # Ensure log directory exists
642
+ log_path.parent.mkdir(parents=True, exist_ok=True)
643
+
644
+ # Append to log file
645
+ with open(log_path, "a", encoding="utf-8") as f:
646
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
647
+ f.write(
648
+ f"[{timestamp}] {deletion_info['type'].title()} '{deletion_info['name']}' "
649
+ f"(Path: {deletion_info['path']}) has been deleted or moved\n"
650
+ )
651
+ except Exception:
652
+ # Silently ignore log errors to avoid breaking property access
653
+ pass
654
+
655
+ def _load_baselines(self) -> None:
656
+ """Load baselines from the baseline file."""
657
+ if not self._baseline_file:
658
+ return
659
+
660
+ # Resolve baseline file path (relative paths are relative to current working directory)
661
+ baseline_path = Path(self._baseline_file)
662
+ if not baseline_path.is_absolute():
663
+ baseline_path = Path.cwd() / baseline_path
664
+
665
+ if not baseline_path.exists():
666
+ return
667
+
668
+ try:
669
+ with open(baseline_path, "r", encoding="utf-8") as f:
670
+ data = json.load(f)
671
+ self._baselines = data.get("baselines", {})
672
+ self._accessed_items = data.get("accessed_items", {})
673
+ self._accessed_properties = data.get("accessed_properties", {})
674
+ except Exception:
675
+ # If loading fails, start with empty baselines
676
+ self._baselines = {}
677
+ self._accessed_items = {}
678
+ self._accessed_properties = {}
679
+
680
+ def _save_baselines(self) -> None:
681
+ """Save baselines to the baseline file.
682
+
683
+ Only saves baselines for properties that are currently being tracked,
684
+ keeping the baseline file clean and containing only relevant data.
685
+ """
686
+ if not self._baseline_file:
687
+ return
688
+
689
+ try:
690
+ # Resolve baseline file path (relative paths are relative to current working directory)
691
+ baseline_path = Path(self._baseline_file)
692
+ if not baseline_path.is_absolute():
693
+ baseline_path = Path.cwd() / baseline_path
694
+
695
+ # Ensure directory exists
696
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
697
+
698
+ # Only keep baselines for properties that are currently being tracked
699
+ # This keeps the baseline file clean and removes old/stale data
700
+ # Note: We keep all baselines that exist, but we'll clean up deleted ones
701
+ # The filtering happens when items/properties are deleted, not here
702
+ filtered_baselines = self._baselines.copy()
703
+
704
+ # Save to file
705
+ with open(baseline_path, "w", encoding="utf-8") as f:
706
+ json.dump(
707
+ {
708
+ "baselines": filtered_baselines,
709
+ "accessed_items": self._accessed_items,
710
+ "accessed_properties": self._accessed_properties,
711
+ "last_updated": time.time(),
712
+ },
713
+ f,
714
+ indent=2,
715
+ )
716
+ except Exception:
717
+ # Silently ignore save errors to avoid breaking property access
718
+ pass
719
+
720
+ def _log_change(self, change_info: Dict[str, Any]) -> None:
721
+ """Log a single change to the log file.
722
+
723
+ Args:
724
+ change_info: Dictionary containing change information.
725
+ """
726
+ if not self._log_file:
727
+ return
728
+
729
+ try:
730
+ # Resolve log file path (relative paths are relative to current working directory)
731
+ log_path = Path(self._log_file)
732
+ # If it's a relative path, resolve it relative to current working directory
733
+ if not log_path.is_absolute():
734
+ log_path = Path.cwd() / log_path
735
+
736
+ # Ensure log directory exists
737
+ log_path.parent.mkdir(parents=True, exist_ok=True)
738
+
739
+ # Append to log file
740
+ with open(log_path, "a", encoding="utf-8") as f:
741
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
742
+ message_parts = [
743
+ f"[{timestamp}] Property '{change_info['name']}' "
744
+ f"changed: {change_info['old_value']} → {change_info['new_value']}"
745
+ ]
746
+
747
+ # Add updated_by and updated_at if available
748
+ if change_info.get("updated_by"):
749
+ message_parts.append(f"(updated by {change_info['updated_by']}")
750
+ if change_info.get("updated_at"):
751
+ # Format updated_at in the same style as the log prefix
752
+ try:
753
+ dt = datetime.fromisoformat(
754
+ change_info["updated_at"].replace("Z", "+00:00")
755
+ )
756
+ formatted_updated_at = dt.strftime("%Y-%m-%d %H:%M:%S")
757
+ except Exception:
758
+ formatted_updated_at = change_info["updated_at"]
759
+ message_parts.append(f"at {formatted_updated_at})")
760
+
761
+ # Fallback to time since first access if updated_at not available
762
+ if not change_info.get("updated_at"):
763
+ time_str = self._format_time_delta(change_info["time_since_first_access"])
764
+ message_parts.append(f"(first accessed {time_str})")
765
+
766
+ f.write(" ".join(message_parts) + "\n")
767
+ except Exception:
768
+ # Silently ignore log errors to avoid breaking property access
769
+ pass