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.
- poelis_sdk/__init__.py +30 -0
- poelis_sdk/_transport.py +147 -0
- poelis_sdk/browser.py +1998 -0
- poelis_sdk/change_tracker.py +769 -0
- poelis_sdk/client.py +204 -0
- poelis_sdk/exceptions.py +44 -0
- poelis_sdk/items.py +121 -0
- poelis_sdk/logging.py +73 -0
- poelis_sdk/models.py +183 -0
- poelis_sdk/org_validation.py +163 -0
- poelis_sdk/products.py +167 -0
- poelis_sdk/search.py +88 -0
- poelis_sdk/versions.py +123 -0
- poelis_sdk/workspaces.py +50 -0
- poelis_sdk-0.5.4.dist-info/METADATA +113 -0
- poelis_sdk-0.5.4.dist-info/RECORD +18 -0
- poelis_sdk-0.5.4.dist-info/WHEEL +4 -0
- poelis_sdk-0.5.4.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|