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
poelis_sdk/browser.py
ADDED
|
@@ -0,0 +1,1998 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from types import MethodType
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .org_validation import get_organization_context_message
|
|
9
|
+
|
|
10
|
+
"""GraphQL-backed dot-path browser for Poelis SDK.
|
|
11
|
+
|
|
12
|
+
Provides lazy, name-based navigation across workspaces → products → items → child items,
|
|
13
|
+
with optional property listing on items. Designed for notebook UX.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Internal guard to avoid repeated completer installation
|
|
18
|
+
_AUTO_COMPLETER_INSTALLED: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Node:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client: Any,
|
|
25
|
+
level: str,
|
|
26
|
+
parent: Optional["_Node"],
|
|
27
|
+
node_id: Optional[str],
|
|
28
|
+
name: Optional[str],
|
|
29
|
+
version_number: Optional[int] = None,
|
|
30
|
+
baseline_version_number: Optional[int] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._client = client
|
|
33
|
+
self._level = level
|
|
34
|
+
self._parent = parent
|
|
35
|
+
self._id = node_id
|
|
36
|
+
self._name = name
|
|
37
|
+
self._version_number: Optional[int] = version_number # Track version context for items
|
|
38
|
+
# For product nodes, track the configured baseline version number (if any).
|
|
39
|
+
# When set, this should be preferred over "latest version" for baseline semantics.
|
|
40
|
+
self._baseline_version_number: Optional[int] = baseline_version_number
|
|
41
|
+
self._children_cache: Dict[str, "_Node"] = {}
|
|
42
|
+
self._props_cache: Optional[List[Dict[str, Any]]] = None
|
|
43
|
+
# Performance optimization: cache metadata with TTL
|
|
44
|
+
self._children_loaded_at: Optional[float] = None
|
|
45
|
+
self._props_loaded_at: Optional[float] = None
|
|
46
|
+
self._cache_ttl: float = 30.0 # 30 seconds cache TTL
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str: # pragma: no cover - notebook UX
|
|
49
|
+
path = []
|
|
50
|
+
cur: Optional[_Node] = self
|
|
51
|
+
while cur is not None and cur._name:
|
|
52
|
+
path.append(cur._name)
|
|
53
|
+
cur = cur._parent
|
|
54
|
+
return f"<{self._level}:{'.'.join(reversed(path)) or '*'}>"
|
|
55
|
+
|
|
56
|
+
def _build_path(self, attr: str) -> Optional[str]:
|
|
57
|
+
"""Build a path string for tracking items/properties.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
attr: Attribute name being accessed.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Optional[str]: Path string like "workspace.product.item" or "workspace.product.item.property", None if invalid.
|
|
64
|
+
"""
|
|
65
|
+
if self._level == "root":
|
|
66
|
+
return None # Root level doesn't have a path
|
|
67
|
+
|
|
68
|
+
path_parts = []
|
|
69
|
+
cur: Optional[_Node] = self
|
|
70
|
+
|
|
71
|
+
# Build path from current node up to root
|
|
72
|
+
while cur is not None and cur._level != "root":
|
|
73
|
+
if cur._name:
|
|
74
|
+
path_parts.append(cur._name)
|
|
75
|
+
cur = cur._parent
|
|
76
|
+
|
|
77
|
+
# Reverse to get root-to-current order
|
|
78
|
+
path_parts.reverse()
|
|
79
|
+
|
|
80
|
+
# Add the attribute being accessed
|
|
81
|
+
if attr:
|
|
82
|
+
path_parts.append(attr)
|
|
83
|
+
|
|
84
|
+
return ".".join(path_parts) if path_parts else None
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str: # pragma: no cover - notebook UX
|
|
87
|
+
"""Return the display name of this node for string conversion.
|
|
88
|
+
|
|
89
|
+
This allows items to be printed directly and show just their name,
|
|
90
|
+
while repr() still shows the full path for debugging.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
str: The human-friendly display name, or empty string if unknown.
|
|
94
|
+
"""
|
|
95
|
+
return self._name or ""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def name(self) -> Optional[str]:
|
|
99
|
+
"""Return the display name of this node if available.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Optional[str]: The human-friendly display name, or None if unknown.
|
|
103
|
+
"""
|
|
104
|
+
return self._name
|
|
105
|
+
|
|
106
|
+
def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
|
|
107
|
+
# Performance optimization: only load children if cache is stale or empty
|
|
108
|
+
if self._is_children_cache_stale():
|
|
109
|
+
self._load_children()
|
|
110
|
+
keys = list(self._children_cache.keys())
|
|
111
|
+
if self._level == "item":
|
|
112
|
+
# Include property names directly on item for suggestions
|
|
113
|
+
prop_keys = list(self._props_key_map().keys())
|
|
114
|
+
keys.extend(prop_keys)
|
|
115
|
+
keys.extend(["list_items", "list_properties", "get_property"])
|
|
116
|
+
elif self._level == "product":
|
|
117
|
+
# At product level, show items from baseline (latest version) + helper methods + version names
|
|
118
|
+
keys.extend(["list_items", "list_product_versions", "baseline", "draft", "get_version", "get_property"])
|
|
119
|
+
# Include version names (v1, v2, etc.) in autocomplete suggestions
|
|
120
|
+
version_names = self._get_version_names()
|
|
121
|
+
keys.extend(version_names)
|
|
122
|
+
elif self._level == "version":
|
|
123
|
+
keys.extend(["list_items", "get_property"])
|
|
124
|
+
elif self._level == "workspace":
|
|
125
|
+
keys.append("list_products")
|
|
126
|
+
elif self._level == "root":
|
|
127
|
+
keys.append("list_workspaces")
|
|
128
|
+
return sorted(set(keys))
|
|
129
|
+
|
|
130
|
+
# Intentionally no public id/name/refresh to keep suggestions minimal
|
|
131
|
+
def _refresh(self) -> "_Node":
|
|
132
|
+
self._children_cache.clear()
|
|
133
|
+
self._props_cache = None
|
|
134
|
+
self._children_loaded_at = None
|
|
135
|
+
self._props_loaded_at = None
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def _is_children_cache_stale(self) -> bool:
|
|
139
|
+
"""Check if children cache is stale and needs refresh."""
|
|
140
|
+
if not self._children_cache:
|
|
141
|
+
return True
|
|
142
|
+
if self._children_loaded_at is None:
|
|
143
|
+
return True
|
|
144
|
+
return time.time() - self._children_loaded_at > self._cache_ttl
|
|
145
|
+
|
|
146
|
+
def _is_props_cache_stale(self) -> bool:
|
|
147
|
+
"""Check if properties cache is stale and needs refresh."""
|
|
148
|
+
if self._props_cache is None:
|
|
149
|
+
return True
|
|
150
|
+
if self._props_loaded_at is None:
|
|
151
|
+
return True
|
|
152
|
+
return time.time() - self._props_loaded_at > self._cache_ttl
|
|
153
|
+
|
|
154
|
+
def _names(self) -> List[str]:
|
|
155
|
+
"""Return display names of children at this level (internal).
|
|
156
|
+
|
|
157
|
+
For item level, include both child item names and property display names.
|
|
158
|
+
"""
|
|
159
|
+
if self._is_children_cache_stale():
|
|
160
|
+
self._load_children()
|
|
161
|
+
child_names = [child._name or "" for child in self._children_cache.values()]
|
|
162
|
+
if self._level == "item":
|
|
163
|
+
props = self._properties()
|
|
164
|
+
prop_names: List[str] = []
|
|
165
|
+
for i, pr in enumerate(props):
|
|
166
|
+
display = pr.get("readableId") or pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
|
|
167
|
+
prop_names.append(str(display))
|
|
168
|
+
return child_names + prop_names
|
|
169
|
+
return child_names
|
|
170
|
+
|
|
171
|
+
# names() removed in favor of list_*().names
|
|
172
|
+
|
|
173
|
+
# --- Node-list helpers ---
|
|
174
|
+
def _list_workspaces(self) -> "_NodeList":
|
|
175
|
+
if self._level != "root":
|
|
176
|
+
return _NodeList([], [])
|
|
177
|
+
if self._is_children_cache_stale():
|
|
178
|
+
self._load_children()
|
|
179
|
+
items = list(self._children_cache.values())
|
|
180
|
+
names = [n._name or "" for n in items]
|
|
181
|
+
return _NodeList(items, names)
|
|
182
|
+
|
|
183
|
+
def _list_products(self) -> "_NodeList":
|
|
184
|
+
if self._level != "workspace":
|
|
185
|
+
return _NodeList([], [])
|
|
186
|
+
if self._is_children_cache_stale():
|
|
187
|
+
self._load_children()
|
|
188
|
+
items = list(self._children_cache.values())
|
|
189
|
+
names = [n._name or "" for n in items]
|
|
190
|
+
return _NodeList(items, names)
|
|
191
|
+
|
|
192
|
+
def _list_items(self) -> "_NodeList":
|
|
193
|
+
if self._level not in ("product", "item", "version"):
|
|
194
|
+
return _NodeList([], [])
|
|
195
|
+
# If called on a product node, delegate to baseline version:
|
|
196
|
+
# - Prefer the configured baseline_version_number if available
|
|
197
|
+
# - Otherwise use the latest version (highest version_number)
|
|
198
|
+
if self._level == "product":
|
|
199
|
+
try:
|
|
200
|
+
# First, try to use configured baseline_version_number from the product model
|
|
201
|
+
version_number: Optional[int] = getattr(self, "_baseline_version_number", None)
|
|
202
|
+
if version_number is None:
|
|
203
|
+
# Fallback to latest version from backend if no baseline is configured
|
|
204
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
205
|
+
versions = getattr(page, "data", []) or []
|
|
206
|
+
if versions:
|
|
207
|
+
latest_version = max(versions, key=lambda v: getattr(v, "version_number", 0))
|
|
208
|
+
version_number = getattr(latest_version, "version_number", None)
|
|
209
|
+
if version_number is not None:
|
|
210
|
+
# Create baseline version node and delegate to it
|
|
211
|
+
baseline_node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
212
|
+
baseline_node._cache_ttl = self._cache_ttl
|
|
213
|
+
return baseline_node._list_items()
|
|
214
|
+
# If no versions found, fall back to draft
|
|
215
|
+
draft_node = _Node(self._client, "version", self, None, "draft")
|
|
216
|
+
draft_node._cache_ttl = self._cache_ttl
|
|
217
|
+
return draft_node._list_items()
|
|
218
|
+
except Exception:
|
|
219
|
+
# On error, fall back to draft
|
|
220
|
+
draft_node = _Node(self._client, "version", self, None, "draft")
|
|
221
|
+
draft_node._cache_ttl = self._cache_ttl
|
|
222
|
+
return draft_node._list_items()
|
|
223
|
+
if self._is_children_cache_stale():
|
|
224
|
+
self._load_children()
|
|
225
|
+
items = list(self._children_cache.values())
|
|
226
|
+
names = [n._name or "" for n in items]
|
|
227
|
+
return _NodeList(items, names)
|
|
228
|
+
|
|
229
|
+
def _list_properties(self) -> "_NodeList":
|
|
230
|
+
if self._level != "item":
|
|
231
|
+
return _NodeList([], [])
|
|
232
|
+
props = self._properties()
|
|
233
|
+
wrappers: List[_PropWrapper] = []
|
|
234
|
+
names: List[str] = []
|
|
235
|
+
for i, pr in enumerate(props):
|
|
236
|
+
display = pr.get("readableId") or pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
|
|
237
|
+
names.append(str(display))
|
|
238
|
+
wrappers.append(_PropWrapper(pr, client=self._client))
|
|
239
|
+
return _NodeList(wrappers, names)
|
|
240
|
+
|
|
241
|
+
def _get_property(self, readable_id: str) -> "_PropWrapper":
|
|
242
|
+
"""Get a property by its readableId from this product version.
|
|
243
|
+
|
|
244
|
+
Searches for a property with the given readableId across all items
|
|
245
|
+
in this product version. The readableId is unique within a product,
|
|
246
|
+
so this will return the property regardless of which item it belongs to.
|
|
247
|
+
|
|
248
|
+
When called on a product node, it uses the baseline (latest version)
|
|
249
|
+
as the default.
|
|
250
|
+
|
|
251
|
+
When called on an item node, it searches recursively through the item
|
|
252
|
+
and all its sub-items to find the property.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
readable_id: The readableId of the property to retrieve
|
|
256
|
+
(e.g., "demo_property_mass").
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
_PropWrapper: A wrapper object providing access to the property's
|
|
260
|
+
value, category, unit, and other attributes.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
AttributeError: If called on a non-product, non-version, or non-item node.
|
|
264
|
+
RuntimeError: If the property cannot be found or if there's an
|
|
265
|
+
error querying the GraphQL API.
|
|
266
|
+
"""
|
|
267
|
+
# If called on a product node, delegate to baseline version (configured baseline or latest).
|
|
268
|
+
if self._level == "product":
|
|
269
|
+
try:
|
|
270
|
+
# First, try to use configured baseline_version_number from the product model
|
|
271
|
+
version_number: Optional[int] = getattr(self, "_baseline_version_number", None)
|
|
272
|
+
if version_number is None:
|
|
273
|
+
# Fallback to latest version from backend if no baseline is configured
|
|
274
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
275
|
+
versions = getattr(page, "data", []) or []
|
|
276
|
+
if versions:
|
|
277
|
+
latest_version = max(versions, key=lambda v: getattr(v, "version_number", 0))
|
|
278
|
+
version_number = getattr(latest_version, "version_number", None)
|
|
279
|
+
if version_number is not None:
|
|
280
|
+
# Create baseline version node and delegate to it
|
|
281
|
+
baseline_node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
282
|
+
baseline_node._cache_ttl = self._cache_ttl
|
|
283
|
+
return baseline_node._get_property(readable_id)
|
|
284
|
+
# If no versions found, fall back to draft
|
|
285
|
+
draft_node = _Node(self._client, "version", self, None, "draft")
|
|
286
|
+
draft_node._cache_ttl = self._cache_ttl
|
|
287
|
+
return draft_node._get_property(readable_id)
|
|
288
|
+
except Exception:
|
|
289
|
+
# On error, fall back to draft
|
|
290
|
+
draft_node = _Node(self._client, "version", self, None, "draft")
|
|
291
|
+
draft_node._cache_ttl = self._cache_ttl
|
|
292
|
+
return draft_node._get_property(readable_id)
|
|
293
|
+
|
|
294
|
+
# If called on an item node, search recursively through the item and its sub-items
|
|
295
|
+
if self._level == "item":
|
|
296
|
+
return self._get_property_from_item_tree(readable_id)
|
|
297
|
+
|
|
298
|
+
if self._level != "version":
|
|
299
|
+
raise AttributeError("get_property() method is only available on product, version, and item nodes")
|
|
300
|
+
|
|
301
|
+
# Get product_id from ancestor
|
|
302
|
+
anc = self
|
|
303
|
+
pid: Optional[str] = None
|
|
304
|
+
while anc is not None:
|
|
305
|
+
if anc._level == "product":
|
|
306
|
+
pid = anc._id
|
|
307
|
+
break
|
|
308
|
+
anc = anc._parent # type: ignore[assignment]
|
|
309
|
+
|
|
310
|
+
if not pid:
|
|
311
|
+
raise RuntimeError("Cannot determine product ID for version node")
|
|
312
|
+
|
|
313
|
+
# Get version number (None for draft)
|
|
314
|
+
version_number: Optional[int] = None
|
|
315
|
+
if self._id is not None:
|
|
316
|
+
try:
|
|
317
|
+
version_number = int(self._id)
|
|
318
|
+
except (TypeError, ValueError):
|
|
319
|
+
version_number = None
|
|
320
|
+
|
|
321
|
+
# Search for property by readableId
|
|
322
|
+
# Since searchProperties doesn't return readableId, we need to iterate through items
|
|
323
|
+
# and query their properties directly. Since readableId is unique per product,
|
|
324
|
+
# we only need to find it once.
|
|
325
|
+
#
|
|
326
|
+
# When change detection is enabled, we prefer sdkProperties so that we get
|
|
327
|
+
# updatedAt/updatedBy metadata for draft properties (and potentially versions).
|
|
328
|
+
use_sdk_properties = False
|
|
329
|
+
try:
|
|
330
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
331
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
332
|
+
use_sdk_properties = True
|
|
333
|
+
except Exception:
|
|
334
|
+
# If anything goes wrong determining this, fall back to regular properties.
|
|
335
|
+
use_sdk_properties = False
|
|
336
|
+
|
|
337
|
+
# Get all items in this product version
|
|
338
|
+
if version_number is not None:
|
|
339
|
+
items = self._client.versions.list_items(
|
|
340
|
+
product_id=pid,
|
|
341
|
+
version_number=version_number,
|
|
342
|
+
limit=1000,
|
|
343
|
+
offset=0,
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
items = self._client.items.list_by_product(product_id=pid, limit=1000, offset=0)
|
|
347
|
+
|
|
348
|
+
# Query properties for each item until we find the one with matching readableId.
|
|
349
|
+
for item in items:
|
|
350
|
+
item_id = item.get("id")
|
|
351
|
+
if not item_id:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Query properties for this item. Prefer sdkProperties when change
|
|
355
|
+
# detection is enabled so we also get updatedAt/updatedBy metadata.
|
|
356
|
+
query_name = "sdkProperties" if use_sdk_properties else "properties"
|
|
357
|
+
property_type_prefix = "Sdk" if use_sdk_properties else ""
|
|
358
|
+
|
|
359
|
+
# First try the richer parsedValue + updatedAt/updatedBy shape.
|
|
360
|
+
updated_fields = " updatedAt updatedBy" if use_sdk_properties else ""
|
|
361
|
+
if version_number is not None:
|
|
362
|
+
prop_query = (
|
|
363
|
+
f"query($iid: ID!, $version: VersionInput!) {{\n"
|
|
364
|
+
f" {query_name}(itemId: $iid, version: $version) {{\n"
|
|
365
|
+
f" __typename\n"
|
|
366
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
367
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
368
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
369
|
+
f" }}\n"
|
|
370
|
+
f"}}"
|
|
371
|
+
)
|
|
372
|
+
prop_variables = {
|
|
373
|
+
"iid": item_id,
|
|
374
|
+
"version": {"productId": pid, "versionNumber": version_number},
|
|
375
|
+
}
|
|
376
|
+
else:
|
|
377
|
+
prop_query = (
|
|
378
|
+
f"query($iid: ID!) {{\n"
|
|
379
|
+
f" {query_name}(itemId: $iid) {{\n"
|
|
380
|
+
f" __typename\n"
|
|
381
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
382
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
383
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
384
|
+
f" }}\n"
|
|
385
|
+
f"}}"
|
|
386
|
+
)
|
|
387
|
+
prop_variables = {"iid": item_id}
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
r = self._client._transport.graphql(prop_query, prop_variables)
|
|
391
|
+
r.raise_for_status()
|
|
392
|
+
data = r.json()
|
|
393
|
+
if "errors" in data:
|
|
394
|
+
# If sdkProperties is not available or doesn't support the
|
|
395
|
+
# given parameters, fall back to the legacy properties API.
|
|
396
|
+
if use_sdk_properties:
|
|
397
|
+
# Fallback: plain properties without parsedValue/updatedAt/updatedBy.
|
|
398
|
+
if version_number is not None:
|
|
399
|
+
fallback_query = (
|
|
400
|
+
"query($iid: ID!, $version: VersionInput!) {\n"
|
|
401
|
+
" properties(itemId: $iid, version: $version) {\n"
|
|
402
|
+
" __typename\n"
|
|
403
|
+
" ... on NumericProperty { id name readableId category displayUnit value parsedValue }\n"
|
|
404
|
+
" ... on TextProperty { id name readableId value parsedValue }\n"
|
|
405
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
406
|
+
" }\n"
|
|
407
|
+
"}"
|
|
408
|
+
)
|
|
409
|
+
fallback_vars = {
|
|
410
|
+
"iid": item_id,
|
|
411
|
+
"version": {"productId": pid, "versionNumber": version_number},
|
|
412
|
+
}
|
|
413
|
+
else:
|
|
414
|
+
fallback_query = (
|
|
415
|
+
"query($iid: ID!) {\n"
|
|
416
|
+
" properties(itemId: $iid) {\n"
|
|
417
|
+
" __typename\n"
|
|
418
|
+
" ... on NumericProperty { id name readableId category displayUnit value parsedValue }\n"
|
|
419
|
+
" ... on TextProperty { id name readableId value parsedValue }\n"
|
|
420
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
421
|
+
" }\n"
|
|
422
|
+
"}"
|
|
423
|
+
)
|
|
424
|
+
fallback_vars = {"iid": item_id}
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
r_fb = self._client._transport.graphql(fallback_query, fallback_vars)
|
|
428
|
+
r_fb.raise_for_status()
|
|
429
|
+
data = r_fb.json()
|
|
430
|
+
if "errors" in data:
|
|
431
|
+
continue
|
|
432
|
+
except Exception:
|
|
433
|
+
# Skip this item if fallback also fails
|
|
434
|
+
continue
|
|
435
|
+
else:
|
|
436
|
+
# If we were already using properties, skip this item on error.
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
props = data.get("data", {}).get(query_name, []) or []
|
|
440
|
+
# When falling back to properties, the field name is "properties"
|
|
441
|
+
if not props:
|
|
442
|
+
props = data.get("data", {}).get("properties", []) or []
|
|
443
|
+
|
|
444
|
+
# Look for property with matching readableId
|
|
445
|
+
for prop in props:
|
|
446
|
+
if prop.get("readableId") == readable_id:
|
|
447
|
+
wrapper = _PropWrapper(prop, client=self._client)
|
|
448
|
+
# Track accessed property for deletion/change detection
|
|
449
|
+
if self._client is not None:
|
|
450
|
+
try:
|
|
451
|
+
change_tracker2 = getattr(self._client, "_change_tracker", None)
|
|
452
|
+
if change_tracker2 is not None and change_tracker2.is_enabled():
|
|
453
|
+
property_path = self._build_path(readable_id)
|
|
454
|
+
if property_path:
|
|
455
|
+
prop_name = (
|
|
456
|
+
prop.get("readableId")
|
|
457
|
+
or prop.get("name")
|
|
458
|
+
or readable_id
|
|
459
|
+
)
|
|
460
|
+
prop_id = prop.get("id")
|
|
461
|
+
change_tracker2.record_accessed_property(
|
|
462
|
+
property_path, prop_name, prop_id
|
|
463
|
+
)
|
|
464
|
+
except Exception:
|
|
465
|
+
pass # Silently ignore tracking errors
|
|
466
|
+
return wrapper
|
|
467
|
+
except Exception:
|
|
468
|
+
# Skip this item if there's an error
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
# If not found, raise an error
|
|
472
|
+
raise RuntimeError(
|
|
473
|
+
f"Property with readableId '{readable_id}' not found in product version "
|
|
474
|
+
f"{f'v{version_number}' if version_number is not None else 'draft'}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _get_property_from_item_tree(self, readable_id: str) -> "_PropWrapper":
|
|
478
|
+
"""Get a property by readableId from this item and all its sub-items recursively.
|
|
479
|
+
|
|
480
|
+
Searches for a property with the given readableId starting from this item,
|
|
481
|
+
then recursively searching through all sub-items.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
readable_id: The readableId of the property to retrieve.
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
_PropWrapper: A wrapper object providing access to the property's
|
|
488
|
+
value, category, unit, and other attributes.
|
|
489
|
+
|
|
490
|
+
Raises:
|
|
491
|
+
RuntimeError: If the property cannot be found.
|
|
492
|
+
"""
|
|
493
|
+
# Get product_id and version_number from ancestors
|
|
494
|
+
anc = self
|
|
495
|
+
pid: Optional[str] = None
|
|
496
|
+
version_number: Optional[int] = None
|
|
497
|
+
|
|
498
|
+
while anc is not None:
|
|
499
|
+
if anc._level == "product":
|
|
500
|
+
pid = anc._id
|
|
501
|
+
elif anc._level == "version":
|
|
502
|
+
if anc._id is not None:
|
|
503
|
+
try:
|
|
504
|
+
version_number = int(anc._id)
|
|
505
|
+
except (TypeError, ValueError):
|
|
506
|
+
version_number = None
|
|
507
|
+
else:
|
|
508
|
+
version_number = None
|
|
509
|
+
elif anc._level == "item":
|
|
510
|
+
# Check if this item has a version_number attribute
|
|
511
|
+
item_version = getattr(anc, "_version_number", None)
|
|
512
|
+
if item_version is not None:
|
|
513
|
+
version_number = item_version
|
|
514
|
+
anc = anc._parent # type: ignore[assignment]
|
|
515
|
+
|
|
516
|
+
if not pid:
|
|
517
|
+
raise RuntimeError("Cannot determine product ID for item node")
|
|
518
|
+
|
|
519
|
+
# Recursively search through this item and all sub-items
|
|
520
|
+
return self._search_property_in_item_and_children(
|
|
521
|
+
self._id, readable_id, pid, version_number
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def _search_property_in_item_and_children(
|
|
525
|
+
self, item_id: Optional[str], readable_id: str, product_id: str, version_number: Optional[int]
|
|
526
|
+
) -> "_PropWrapper":
|
|
527
|
+
"""Recursively search for a property in an item and all its children.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
item_id: The ID of the item to search.
|
|
531
|
+
readable_id: The readableId of the property to find.
|
|
532
|
+
product_id: The product ID.
|
|
533
|
+
version_number: Optional version number (None for draft).
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
_PropWrapper: The found property.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
RuntimeError: If the property is not found.
|
|
540
|
+
"""
|
|
541
|
+
if not item_id:
|
|
542
|
+
raise RuntimeError(f"Property with readableId '{readable_id}' not found")
|
|
543
|
+
|
|
544
|
+
# Query properties for this item. Prefer sdkProperties when change
|
|
545
|
+
# detection is enabled so we also get updatedAt/updatedBy metadata.
|
|
546
|
+
use_sdk_properties = False
|
|
547
|
+
try:
|
|
548
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
549
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
550
|
+
use_sdk_properties = True
|
|
551
|
+
except Exception:
|
|
552
|
+
use_sdk_properties = False
|
|
553
|
+
|
|
554
|
+
query_name = "sdkProperties" if use_sdk_properties else "properties"
|
|
555
|
+
property_type_prefix = "Sdk" if use_sdk_properties else ""
|
|
556
|
+
|
|
557
|
+
updated_fields = " updatedAt updatedBy" if use_sdk_properties else ""
|
|
558
|
+
if version_number is not None:
|
|
559
|
+
prop_query = (
|
|
560
|
+
f"query($iid: ID!, $version: VersionInput!) {{\n"
|
|
561
|
+
f" {query_name}(itemId: $iid, version: $version) {{\n"
|
|
562
|
+
f" __typename\n"
|
|
563
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
564
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
565
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
566
|
+
f" }}\n"
|
|
567
|
+
f"}}"
|
|
568
|
+
)
|
|
569
|
+
prop_variables = {
|
|
570
|
+
"iid": item_id,
|
|
571
|
+
"version": {"productId": product_id, "versionNumber": version_number},
|
|
572
|
+
}
|
|
573
|
+
else:
|
|
574
|
+
prop_query = (
|
|
575
|
+
f"query($iid: ID!) {{\n"
|
|
576
|
+
f" {query_name}(itemId: $iid) {{\n"
|
|
577
|
+
f" __typename\n"
|
|
578
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
579
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
580
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
581
|
+
f" }}\n"
|
|
582
|
+
f"}}"
|
|
583
|
+
)
|
|
584
|
+
prop_variables = {"iid": item_id}
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
r = self._client._transport.graphql(prop_query, prop_variables)
|
|
588
|
+
r.raise_for_status()
|
|
589
|
+
data = r.json()
|
|
590
|
+
if "errors" in data:
|
|
591
|
+
# If sdkProperties is not available or doesn't support the
|
|
592
|
+
# given parameters, fall back to the legacy properties API.
|
|
593
|
+
if use_sdk_properties:
|
|
594
|
+
if version_number is not None:
|
|
595
|
+
fallback_query = (
|
|
596
|
+
"query($iid: ID!, $version: VersionInput!) {\n"
|
|
597
|
+
" properties(itemId: $iid, version: $version) {\n"
|
|
598
|
+
" __typename\n"
|
|
599
|
+
" ... on NumericProperty { id name readableId category displayUnit value parsedValue }\n"
|
|
600
|
+
" ... on TextProperty { id name readableId value parsedValue }\n"
|
|
601
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
602
|
+
" }\n"
|
|
603
|
+
"}"
|
|
604
|
+
)
|
|
605
|
+
fallback_vars = {
|
|
606
|
+
"iid": item_id,
|
|
607
|
+
"version": {"productId": product_id, "versionNumber": version_number},
|
|
608
|
+
}
|
|
609
|
+
else:
|
|
610
|
+
fallback_query = (
|
|
611
|
+
"query($iid: ID!) {\n"
|
|
612
|
+
" properties(itemId: $iid) {\n"
|
|
613
|
+
" __typename\n"
|
|
614
|
+
" ... on NumericProperty { id name readableId category displayUnit value parsedValue }\n"
|
|
615
|
+
" ... on TextProperty { id name readableId value parsedValue }\n"
|
|
616
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
617
|
+
" }\n"
|
|
618
|
+
"}"
|
|
619
|
+
)
|
|
620
|
+
fallback_vars = {"iid": item_id}
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
r_fb = self._client._transport.graphql(fallback_query, fallback_vars)
|
|
624
|
+
r_fb.raise_for_status()
|
|
625
|
+
data = r_fb.json()
|
|
626
|
+
if "errors" in data:
|
|
627
|
+
raise RuntimeError(data["errors"])
|
|
628
|
+
except Exception:
|
|
629
|
+
# If fallback also fails, treat as no properties for this item.
|
|
630
|
+
data = {"data": {}}
|
|
631
|
+
else:
|
|
632
|
+
raise RuntimeError(data["errors"])
|
|
633
|
+
|
|
634
|
+
props = data.get("data", {}).get(query_name, []) or []
|
|
635
|
+
if not props:
|
|
636
|
+
props = data.get("data", {}).get("properties", []) or []
|
|
637
|
+
|
|
638
|
+
# Look for property with matching readableId in this item
|
|
639
|
+
for prop in props:
|
|
640
|
+
if prop.get("readableId") == readable_id:
|
|
641
|
+
wrapper = _PropWrapper(prop, client=self._client)
|
|
642
|
+
# Track accessed property for deletion/change detection
|
|
643
|
+
if self._client is not None:
|
|
644
|
+
try:
|
|
645
|
+
change_tracker2 = getattr(self._client, "_change_tracker", None)
|
|
646
|
+
if change_tracker2 is not None and change_tracker2.is_enabled():
|
|
647
|
+
property_path = self._build_path(readable_id)
|
|
648
|
+
if property_path:
|
|
649
|
+
prop_name = (
|
|
650
|
+
prop.get("readableId")
|
|
651
|
+
or prop.get("name")
|
|
652
|
+
or readable_id
|
|
653
|
+
)
|
|
654
|
+
prop_id = prop.get("id")
|
|
655
|
+
change_tracker2.record_accessed_property(
|
|
656
|
+
property_path, prop_name, prop_id
|
|
657
|
+
)
|
|
658
|
+
except Exception:
|
|
659
|
+
pass # Silently ignore tracking errors
|
|
660
|
+
return wrapper
|
|
661
|
+
except Exception:
|
|
662
|
+
pass # Continue to search children
|
|
663
|
+
|
|
664
|
+
# If not found in this item, search in children
|
|
665
|
+
# Get child items
|
|
666
|
+
if version_number is not None:
|
|
667
|
+
# Get all items for this version and filter by parent
|
|
668
|
+
all_items = self._client.versions.list_items(
|
|
669
|
+
product_id=product_id,
|
|
670
|
+
version_number=version_number,
|
|
671
|
+
limit=1000,
|
|
672
|
+
offset=0,
|
|
673
|
+
)
|
|
674
|
+
child_items = [it for it in all_items if it.get("parentId") == item_id]
|
|
675
|
+
else:
|
|
676
|
+
# Query for child items using GraphQL
|
|
677
|
+
child_query = (
|
|
678
|
+
"query($pid: ID!, $parent: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
679
|
+
" items(productId: $pid, parentItemId: $parent, limit: $limit, offset: $offset) { id name readableId productId parentId owner position }\n"
|
|
680
|
+
"}"
|
|
681
|
+
)
|
|
682
|
+
try:
|
|
683
|
+
r = self._client._transport.graphql(
|
|
684
|
+
child_query, {"pid": product_id, "parent": item_id, "limit": 1000, "offset": 0}
|
|
685
|
+
)
|
|
686
|
+
r.raise_for_status()
|
|
687
|
+
data = r.json()
|
|
688
|
+
if "errors" in data:
|
|
689
|
+
raise RuntimeError(f"Property with readableId '{readable_id}' not found")
|
|
690
|
+
child_items = data.get("data", {}).get("items", []) or []
|
|
691
|
+
except Exception:
|
|
692
|
+
raise RuntimeError(f"Property with readableId '{readable_id}' not found")
|
|
693
|
+
|
|
694
|
+
# Recursively search in each child
|
|
695
|
+
for child_item in child_items:
|
|
696
|
+
child_id = child_item.get("id")
|
|
697
|
+
if child_id:
|
|
698
|
+
try:
|
|
699
|
+
return self._search_property_in_item_and_children(
|
|
700
|
+
child_id, readable_id, product_id, version_number
|
|
701
|
+
)
|
|
702
|
+
except RuntimeError:
|
|
703
|
+
continue # Try next child
|
|
704
|
+
|
|
705
|
+
# If not found in this item or any children, raise error
|
|
706
|
+
raise RuntimeError(f"Property with readableId '{readable_id}' not found in item tree")
|
|
707
|
+
|
|
708
|
+
def _get_version_names(self) -> List[str]:
|
|
709
|
+
"""Get list of version names (v1, v2, etc.) for this product.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
List[str]: List of version names like ['v1', 'v2', 'v3', ...]
|
|
713
|
+
"""
|
|
714
|
+
if self._level != "product":
|
|
715
|
+
return []
|
|
716
|
+
|
|
717
|
+
version_names: List[str] = []
|
|
718
|
+
try:
|
|
719
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
720
|
+
versions_data = getattr(page, "data", []) or []
|
|
721
|
+
for v in versions_data:
|
|
722
|
+
version_number = getattr(v, "version_number", None)
|
|
723
|
+
if version_number is not None:
|
|
724
|
+
version_name = f"v{version_number}"
|
|
725
|
+
version_names.append(version_name)
|
|
726
|
+
except Exception:
|
|
727
|
+
pass # If versions fail to load, return empty list
|
|
728
|
+
|
|
729
|
+
return version_names
|
|
730
|
+
|
|
731
|
+
def _list_product_versions(self) -> "_NodeList":
|
|
732
|
+
"""Return product versions as a list-like object with `.names`.
|
|
733
|
+
|
|
734
|
+
Only meaningful for product-level nodes; other levels return an empty
|
|
735
|
+
list. Includes a "draft" pseudo-version at the beginning for the current
|
|
736
|
+
working state, followed by versioned snapshots (v1, v2, ...).
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
if self._level != "product":
|
|
740
|
+
return _NodeList([], [])
|
|
741
|
+
|
|
742
|
+
items = []
|
|
743
|
+
names: List[str] = []
|
|
744
|
+
|
|
745
|
+
# Add draft pseudo-version at the beginning
|
|
746
|
+
draft_node = _Node(self._client, "version", self, None, "draft")
|
|
747
|
+
draft_node._cache_ttl = self._cache_ttl
|
|
748
|
+
items.append(draft_node)
|
|
749
|
+
names.append("draft")
|
|
750
|
+
|
|
751
|
+
# Add actual versioned snapshots from backend
|
|
752
|
+
try:
|
|
753
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
754
|
+
for v in getattr(page, "data", []) or []:
|
|
755
|
+
version_number = getattr(v, "version_number", None)
|
|
756
|
+
if version_number is None:
|
|
757
|
+
continue
|
|
758
|
+
name = f"v{version_number}"
|
|
759
|
+
node = _Node(self._client, "version", self, str(version_number), name)
|
|
760
|
+
node._cache_ttl = self._cache_ttl
|
|
761
|
+
items.append(node)
|
|
762
|
+
names.append(name)
|
|
763
|
+
except Exception:
|
|
764
|
+
pass # If versions fail to load, still return draft
|
|
765
|
+
|
|
766
|
+
return _NodeList(items, names)
|
|
767
|
+
|
|
768
|
+
def _get_version(self, version_name: str) -> "_Node":
|
|
769
|
+
"""Get a version node by its title/name.
|
|
770
|
+
|
|
771
|
+
Only meaningful for product-level nodes. Searches through available
|
|
772
|
+
versions to find one matching the given title/name.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
version_name: The title or name of the version to retrieve
|
|
776
|
+
(e.g., "version 1", "v1", or exact title match).
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
_Node: A version node for the matching version.
|
|
780
|
+
|
|
781
|
+
Raises:
|
|
782
|
+
AttributeError: If called on a non-product node.
|
|
783
|
+
ValueError: If no version matches the given name.
|
|
784
|
+
"""
|
|
785
|
+
if self._level != "product":
|
|
786
|
+
raise AttributeError("get_version() method is only available on product nodes")
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
790
|
+
versions = getattr(page, "data", []) or []
|
|
791
|
+
|
|
792
|
+
# Normalize the search term (case-insensitive, strip whitespace)
|
|
793
|
+
search_term = version_name.strip().lower()
|
|
794
|
+
|
|
795
|
+
# Try to find a match by title first
|
|
796
|
+
for v in versions:
|
|
797
|
+
title = getattr(v, "title", None)
|
|
798
|
+
if title and title.strip().lower() == search_term:
|
|
799
|
+
version_number = getattr(v, "version_number", None)
|
|
800
|
+
if version_number is not None:
|
|
801
|
+
node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
802
|
+
node._cache_ttl = self._cache_ttl
|
|
803
|
+
return node
|
|
804
|
+
|
|
805
|
+
# If no exact title match, try partial match
|
|
806
|
+
for v in versions:
|
|
807
|
+
title = getattr(v, "title", None)
|
|
808
|
+
if title and search_term in title.strip().lower():
|
|
809
|
+
version_number = getattr(v, "version_number", None)
|
|
810
|
+
if version_number is not None:
|
|
811
|
+
node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
812
|
+
node._cache_ttl = self._cache_ttl
|
|
813
|
+
return node
|
|
814
|
+
|
|
815
|
+
# If still no match, try matching version number format (e.g., "v1", "1")
|
|
816
|
+
if search_term.startswith("v"):
|
|
817
|
+
try:
|
|
818
|
+
version_num = int(search_term[1:])
|
|
819
|
+
for v in versions:
|
|
820
|
+
version_number = getattr(v, "version_number", None)
|
|
821
|
+
if version_number == version_num:
|
|
822
|
+
node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
823
|
+
node._cache_ttl = self._cache_ttl
|
|
824
|
+
return node
|
|
825
|
+
except ValueError:
|
|
826
|
+
pass
|
|
827
|
+
else:
|
|
828
|
+
# Try as direct version number
|
|
829
|
+
try:
|
|
830
|
+
version_num = int(search_term)
|
|
831
|
+
for v in versions:
|
|
832
|
+
version_number = getattr(v, "version_number", None)
|
|
833
|
+
if version_number == version_num:
|
|
834
|
+
node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
835
|
+
node._cache_ttl = self._cache_ttl
|
|
836
|
+
return node
|
|
837
|
+
except ValueError:
|
|
838
|
+
pass
|
|
839
|
+
|
|
840
|
+
# If no match found, raise an error
|
|
841
|
+
available_titles = [getattr(v, "title", f"v{getattr(v, 'version_number', '?')}") for v in versions]
|
|
842
|
+
raise ValueError(
|
|
843
|
+
f"No version found matching '{version_name}'. "
|
|
844
|
+
f"Available versions: {', '.join(available_titles)}"
|
|
845
|
+
)
|
|
846
|
+
except Exception as e:
|
|
847
|
+
if isinstance(e, ValueError):
|
|
848
|
+
raise
|
|
849
|
+
# On other errors, provide a helpful message
|
|
850
|
+
raise ValueError(f"Error retrieving versions: {e}")
|
|
851
|
+
|
|
852
|
+
def _suggest(self) -> List[str]:
|
|
853
|
+
"""Return suggested attribute names for interactive usage.
|
|
854
|
+
|
|
855
|
+
Only child keys are returned; for item level, property keys are also included.
|
|
856
|
+
"""
|
|
857
|
+
if self._is_children_cache_stale():
|
|
858
|
+
self._load_children()
|
|
859
|
+
suggestions: List[str] = list(self._children_cache.keys())
|
|
860
|
+
if self._level == "item":
|
|
861
|
+
suggestions.extend(list(self._props_key_map().keys()))
|
|
862
|
+
suggestions.extend(["list_items", "list_properties", "get_property"])
|
|
863
|
+
elif self._level == "product":
|
|
864
|
+
# At product level, show items from baseline (latest version) + helper methods + version names
|
|
865
|
+
suggestions.extend(["list_items", "list_product_versions", "baseline", "draft", "get_version", "get_property"])
|
|
866
|
+
# Include version names (v1, v2, etc.) in autocomplete suggestions
|
|
867
|
+
version_names = self._get_version_names()
|
|
868
|
+
suggestions.extend(version_names)
|
|
869
|
+
elif self._level == "version":
|
|
870
|
+
suggestions.extend(["list_items", "get_property"])
|
|
871
|
+
elif self._level == "workspace":
|
|
872
|
+
suggestions.append("list_products")
|
|
873
|
+
elif self._level == "root":
|
|
874
|
+
suggestions.append("list_workspaces")
|
|
875
|
+
return sorted(set(suggestions))
|
|
876
|
+
|
|
877
|
+
def __getattr__(self, attr: str) -> Any:
|
|
878
|
+
# No public properties/id/name/refresh
|
|
879
|
+
if attr == "props": # item-level properties pseudo-node
|
|
880
|
+
if self._level != "item":
|
|
881
|
+
raise AttributeError("props")
|
|
882
|
+
return _PropsNode(self)
|
|
883
|
+
|
|
884
|
+
# Version pseudo-children for product nodes (e.g., v4, draft, baseline)
|
|
885
|
+
if self._level == "product":
|
|
886
|
+
if attr == "draft":
|
|
887
|
+
node = _Node(self._client, "version", self, None, "draft")
|
|
888
|
+
node._cache_ttl = self._cache_ttl
|
|
889
|
+
return node
|
|
890
|
+
elif attr == "baseline":
|
|
891
|
+
# Return the configured baseline version if available, otherwise latest.
|
|
892
|
+
try:
|
|
893
|
+
# Prefer configured baseline_version_number from the product model
|
|
894
|
+
version_number: Optional[int] = getattr(self, "_baseline_version_number", None)
|
|
895
|
+
if version_number is None:
|
|
896
|
+
# Fallback to latest version from backend if no baseline is configured
|
|
897
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
898
|
+
versions = getattr(page, "data", []) or []
|
|
899
|
+
if versions:
|
|
900
|
+
latest_version = max(versions, key=lambda v: getattr(v, "version_number", 0))
|
|
901
|
+
version_number = getattr(latest_version, "version_number", None)
|
|
902
|
+
if version_number is not None:
|
|
903
|
+
node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
904
|
+
node._cache_ttl = self._cache_ttl
|
|
905
|
+
return node
|
|
906
|
+
# If no versions found, fall back to draft
|
|
907
|
+
node = _Node(self._client, "version", self, None, "draft")
|
|
908
|
+
node._cache_ttl = self._cache_ttl
|
|
909
|
+
return node
|
|
910
|
+
except Exception:
|
|
911
|
+
# On error, fall back to draft
|
|
912
|
+
node = _Node(self._client, "version", self, None, "draft")
|
|
913
|
+
node._cache_ttl = self._cache_ttl
|
|
914
|
+
return node
|
|
915
|
+
elif attr.startswith("v") and attr[1:].isdigit():
|
|
916
|
+
version_number = int(attr[1:])
|
|
917
|
+
node = _Node(self._client, "version", self, str(version_number), attr)
|
|
918
|
+
node._cache_ttl = self._cache_ttl
|
|
919
|
+
return node
|
|
920
|
+
else:
|
|
921
|
+
# For product nodes, default to baseline version for item access:
|
|
922
|
+
# - Prefer configured baseline_version_number if available
|
|
923
|
+
# - Otherwise use latest version from backend
|
|
924
|
+
# First check if it's a list helper - those should work on product directly
|
|
925
|
+
if attr not in ("list_items", "list_product_versions"):
|
|
926
|
+
# Try to get latest version and redirect to it
|
|
927
|
+
try:
|
|
928
|
+
# Prefer configured baseline version if available
|
|
929
|
+
version_number: Optional[int] = getattr(self, "_baseline_version_number", None)
|
|
930
|
+
if version_number is None:
|
|
931
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
932
|
+
versions = getattr(page, "data", []) or []
|
|
933
|
+
if versions:
|
|
934
|
+
latest_version = max(versions, key=lambda v: getattr(v, "version_number", 0))
|
|
935
|
+
version_number = getattr(latest_version, "version_number", None)
|
|
936
|
+
if version_number is not None:
|
|
937
|
+
# Create baseline/latest version node and try to get attr from it
|
|
938
|
+
latest_node = _Node(self._client, "version", self, str(version_number), f"v{version_number}")
|
|
939
|
+
latest_node._cache_ttl = self._cache_ttl
|
|
940
|
+
# Load children for that version node
|
|
941
|
+
if latest_node._is_children_cache_stale():
|
|
942
|
+
latest_node._load_children()
|
|
943
|
+
# Check if the attr exists in that version's children
|
|
944
|
+
if attr in latest_node._children_cache:
|
|
945
|
+
return latest_node._children_cache[attr]
|
|
946
|
+
# If not found in that version, fall through to check draft/default cache
|
|
947
|
+
except Exception:
|
|
948
|
+
pass # Fall through to default behavior (draft)
|
|
949
|
+
|
|
950
|
+
if attr not in self._children_cache:
|
|
951
|
+
if self._is_children_cache_stale():
|
|
952
|
+
self._load_children()
|
|
953
|
+
if attr in self._children_cache:
|
|
954
|
+
child = self._children_cache[attr]
|
|
955
|
+
# Track accessed items for deletion detection
|
|
956
|
+
if self._client is not None:
|
|
957
|
+
try:
|
|
958
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
959
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
960
|
+
item_path = self._build_path(attr)
|
|
961
|
+
if item_path:
|
|
962
|
+
child_name = getattr(child, "_name", attr) or attr
|
|
963
|
+
child_id = getattr(child, "_id", None)
|
|
964
|
+
change_tracker.record_accessed_item(item_path, child_name, child_id)
|
|
965
|
+
except Exception:
|
|
966
|
+
pass # Silently ignore tracking errors
|
|
967
|
+
return child
|
|
968
|
+
# Dynamically expose list helpers only where meaningful
|
|
969
|
+
if attr == "list_workspaces":
|
|
970
|
+
if self._level == "root":
|
|
971
|
+
return MethodType(_Node._list_workspaces, self)
|
|
972
|
+
raise AttributeError(attr)
|
|
973
|
+
if attr == "list_products":
|
|
974
|
+
if self._level == "workspace":
|
|
975
|
+
return MethodType(_Node._list_products, self)
|
|
976
|
+
raise AttributeError(attr)
|
|
977
|
+
if attr == "list_product_versions":
|
|
978
|
+
if self._level == "product":
|
|
979
|
+
return MethodType(_Node._list_product_versions, self)
|
|
980
|
+
raise AttributeError(attr)
|
|
981
|
+
if attr == "get_version":
|
|
982
|
+
if self._level == "product":
|
|
983
|
+
return MethodType(_Node._get_version, self)
|
|
984
|
+
raise AttributeError(attr)
|
|
985
|
+
if attr == "list_items":
|
|
986
|
+
if self._level in ("product", "item", "version"):
|
|
987
|
+
return MethodType(_Node._list_items, self)
|
|
988
|
+
raise AttributeError(attr)
|
|
989
|
+
if attr == "list_properties":
|
|
990
|
+
if self._level == "item":
|
|
991
|
+
return MethodType(_Node._list_properties, self)
|
|
992
|
+
raise AttributeError(attr)
|
|
993
|
+
if attr == "get_property":
|
|
994
|
+
if self._level in ("product", "version", "item"):
|
|
995
|
+
return MethodType(_Node._get_property, self)
|
|
996
|
+
raise AttributeError(attr)
|
|
997
|
+
|
|
998
|
+
# Expose properties as direct attributes on item level
|
|
999
|
+
if self._level == "item":
|
|
1000
|
+
pk = self._props_key_map()
|
|
1001
|
+
if attr in pk:
|
|
1002
|
+
prop_wrapper = pk[attr]
|
|
1003
|
+
# Track accessed properties for deletion detection
|
|
1004
|
+
if self._client is not None:
|
|
1005
|
+
try:
|
|
1006
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1007
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1008
|
+
property_path = self._build_path(attr)
|
|
1009
|
+
if property_path:
|
|
1010
|
+
prop_name = (
|
|
1011
|
+
getattr(prop_wrapper, "_raw", {}).get("readableId")
|
|
1012
|
+
or getattr(prop_wrapper, "_raw", {}).get("name")
|
|
1013
|
+
or attr
|
|
1014
|
+
)
|
|
1015
|
+
prop_id = getattr(prop_wrapper, "_raw", {}).get("id")
|
|
1016
|
+
change_tracker.record_accessed_property(property_path, prop_name, prop_id)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass # Silently ignore tracking errors
|
|
1019
|
+
return prop_wrapper
|
|
1020
|
+
|
|
1021
|
+
# Check if property was previously accessed (deletion detection)
|
|
1022
|
+
if self._client is not None:
|
|
1023
|
+
try:
|
|
1024
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1025
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1026
|
+
property_path = self._build_path(attr)
|
|
1027
|
+
if property_path:
|
|
1028
|
+
change_tracker.warn_if_deleted(property_path=property_path)
|
|
1029
|
+
except Exception:
|
|
1030
|
+
pass # Silently ignore tracking errors
|
|
1031
|
+
|
|
1032
|
+
# Check if item was previously accessed (deletion detection)
|
|
1033
|
+
if self._client is not None:
|
|
1034
|
+
try:
|
|
1035
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1036
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1037
|
+
item_path = self._build_path(attr)
|
|
1038
|
+
if item_path:
|
|
1039
|
+
change_tracker.warn_if_deleted(item_path=item_path)
|
|
1040
|
+
except Exception:
|
|
1041
|
+
pass # Silently ignore tracking errors
|
|
1042
|
+
|
|
1043
|
+
raise AttributeError(attr)
|
|
1044
|
+
|
|
1045
|
+
def __getitem__(self, key: str) -> "_Node":
|
|
1046
|
+
"""Access child by display name or a safe attribute key.
|
|
1047
|
+
|
|
1048
|
+
This enables names with spaces or symbols: browser["Workspace Name"].
|
|
1049
|
+
"""
|
|
1050
|
+
if self._is_children_cache_stale():
|
|
1051
|
+
self._load_children()
|
|
1052
|
+
if key in self._children_cache:
|
|
1053
|
+
return self._children_cache[key]
|
|
1054
|
+
for child in self._children_cache.values():
|
|
1055
|
+
if child._name == key:
|
|
1056
|
+
return child
|
|
1057
|
+
safe = _safe_key(key)
|
|
1058
|
+
if safe in self._children_cache:
|
|
1059
|
+
return self._children_cache[safe]
|
|
1060
|
+
raise KeyError(key)
|
|
1061
|
+
|
|
1062
|
+
def _properties(self) -> List[Dict[str, Any]]:
|
|
1063
|
+
if not self._is_props_cache_stale():
|
|
1064
|
+
return self._props_cache or []
|
|
1065
|
+
if self._level != "item":
|
|
1066
|
+
self._props_cache = []
|
|
1067
|
+
self._props_loaded_at = time.time()
|
|
1068
|
+
return self._props_cache
|
|
1069
|
+
|
|
1070
|
+
# Get version context if available
|
|
1071
|
+
version_number = getattr(self, "_version_number", None)
|
|
1072
|
+
# Get product_id from ancestor
|
|
1073
|
+
anc = self
|
|
1074
|
+
pid: Optional[str] = None
|
|
1075
|
+
while anc is not None:
|
|
1076
|
+
if anc._level == "product":
|
|
1077
|
+
pid = anc._id
|
|
1078
|
+
break
|
|
1079
|
+
anc = anc._parent # type: ignore[assignment]
|
|
1080
|
+
|
|
1081
|
+
# Check if change detection is enabled - if so, use sdkProperties to get updatedAt/updatedBy
|
|
1082
|
+
use_sdk_properties = False
|
|
1083
|
+
try:
|
|
1084
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1085
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1086
|
+
use_sdk_properties = True
|
|
1087
|
+
except Exception:
|
|
1088
|
+
pass # Silently ignore errors
|
|
1089
|
+
|
|
1090
|
+
# Try direct properties(itemId: ...) or sdkProperties(...) first; fallback to searchProperties
|
|
1091
|
+
# Attempt 1: query with parsedValue support and version if available
|
|
1092
|
+
query_name = "sdkProperties" if use_sdk_properties else "properties"
|
|
1093
|
+
property_type_prefix = "Sdk" if use_sdk_properties else ""
|
|
1094
|
+
|
|
1095
|
+
if version_number is not None and pid is not None:
|
|
1096
|
+
# Note: sdkProperties may not support version parameter yet, but we try it
|
|
1097
|
+
updated_fields = " updatedAt updatedBy" if use_sdk_properties else ""
|
|
1098
|
+
q_parsed = (
|
|
1099
|
+
f"query($iid: ID!, $version: VersionInput!) {{\n"
|
|
1100
|
+
f" {query_name}(itemId: $iid, version: $version) {{\n"
|
|
1101
|
+
f" __typename\n"
|
|
1102
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
1103
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
1104
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
1105
|
+
f" }}\n"
|
|
1106
|
+
f"}}"
|
|
1107
|
+
)
|
|
1108
|
+
variables = {"iid": self._id, "version": {"productId": pid, "versionNumber": version_number}}
|
|
1109
|
+
else:
|
|
1110
|
+
updated_fields = " updatedAt updatedBy" if use_sdk_properties else ""
|
|
1111
|
+
q_parsed = (
|
|
1112
|
+
f"query($iid: ID!) {{\n"
|
|
1113
|
+
f" {query_name}(itemId: $iid) {{\n"
|
|
1114
|
+
f" __typename\n"
|
|
1115
|
+
f" ... on {property_type_prefix}NumericProperty {{ id name readableId category displayUnit value parsedValue{updated_fields} }}\n"
|
|
1116
|
+
f" ... on {property_type_prefix}TextProperty {{ id name readableId value parsedValue{updated_fields} }}\n"
|
|
1117
|
+
f" ... on {property_type_prefix}DateProperty {{ id name readableId value{updated_fields} }}\n"
|
|
1118
|
+
f" }}\n"
|
|
1119
|
+
f"}}"
|
|
1120
|
+
)
|
|
1121
|
+
variables = {"iid": self._id}
|
|
1122
|
+
try:
|
|
1123
|
+
r = self._client._transport.graphql(q_parsed, variables)
|
|
1124
|
+
r.raise_for_status()
|
|
1125
|
+
data = r.json()
|
|
1126
|
+
if "errors" in data:
|
|
1127
|
+
# If sdkProperties query fails, fall back to regular properties query
|
|
1128
|
+
if use_sdk_properties:
|
|
1129
|
+
# sdkProperties might not be available or might not support version parameter
|
|
1130
|
+
# Fall through to try regular properties query
|
|
1131
|
+
pass
|
|
1132
|
+
else:
|
|
1133
|
+
# If versioned query fails, check if it's a version-related error
|
|
1134
|
+
errors = data["errors"]
|
|
1135
|
+
if version_number is not None:
|
|
1136
|
+
error_msg = str(errors)
|
|
1137
|
+
# Check if the error suggests version isn't supported
|
|
1138
|
+
if "version" in error_msg.lower() and ("unknown" in error_msg.lower() or "cannot" in error_msg.lower()):
|
|
1139
|
+
# Properties API likely doesn't support version parameter yet
|
|
1140
|
+
# Fall through to try without version, but this means we'll get draft properties
|
|
1141
|
+
# TODO: Backend needs to add version support to properties API
|
|
1142
|
+
pass
|
|
1143
|
+
else:
|
|
1144
|
+
raise RuntimeError(data["errors"]) # Other errors should be raised
|
|
1145
|
+
else:
|
|
1146
|
+
raise RuntimeError(data["errors"]) # Draft queries should raise on error
|
|
1147
|
+
else:
|
|
1148
|
+
# Handle both properties and sdkProperties responses
|
|
1149
|
+
props_data = data.get("data", {}).get(query_name, []) or []
|
|
1150
|
+
self._props_cache = props_data
|
|
1151
|
+
self._props_loaded_at = time.time()
|
|
1152
|
+
return self._props_cache # Return early if successful
|
|
1153
|
+
except RuntimeError:
|
|
1154
|
+
if not use_sdk_properties:
|
|
1155
|
+
raise # Re-raise RuntimeErrors for regular properties queries
|
|
1156
|
+
# For sdkProperties, fall through to try regular properties query
|
|
1157
|
+
except Exception:
|
|
1158
|
+
if not use_sdk_properties:
|
|
1159
|
+
raise # Re-raise other exceptions for regular properties queries
|
|
1160
|
+
# For sdkProperties, fall through to try regular properties query
|
|
1161
|
+
|
|
1162
|
+
# If sdkProperties failed, try regular properties query as fallback
|
|
1163
|
+
if use_sdk_properties:
|
|
1164
|
+
try:
|
|
1165
|
+
# Fallback to regular properties query (sdkProperties might not be available or might not support version)
|
|
1166
|
+
if version_number is not None and pid is not None:
|
|
1167
|
+
q_value_only = (
|
|
1168
|
+
"query($iid: ID!, $version: VersionInput!) {\n"
|
|
1169
|
+
" properties(itemId: $iid, version: $version) {\n"
|
|
1170
|
+
" __typename\n"
|
|
1171
|
+
" ... on NumericProperty { id name readableId category displayUnit value }\n"
|
|
1172
|
+
" ... on TextProperty { id name readableId value }\n"
|
|
1173
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
1174
|
+
" }\n"
|
|
1175
|
+
"}"
|
|
1176
|
+
)
|
|
1177
|
+
variables = {"iid": self._id, "version": {"productId": pid, "versionNumber": version_number}}
|
|
1178
|
+
else:
|
|
1179
|
+
q_value_only = (
|
|
1180
|
+
"query($iid: ID!) {\n"
|
|
1181
|
+
" properties(itemId: $iid) {\n"
|
|
1182
|
+
" __typename\n"
|
|
1183
|
+
" ... on NumericProperty { id name readableId category displayUnit value }\n"
|
|
1184
|
+
" ... on TextProperty { id name readableId value }\n"
|
|
1185
|
+
" ... on DateProperty { id name readableId value }\n"
|
|
1186
|
+
" }\n"
|
|
1187
|
+
"}"
|
|
1188
|
+
)
|
|
1189
|
+
variables = {"iid": self._id}
|
|
1190
|
+
try:
|
|
1191
|
+
r = self._client._transport.graphql(q_value_only, variables)
|
|
1192
|
+
r.raise_for_status()
|
|
1193
|
+
data = r.json()
|
|
1194
|
+
if "errors" in data:
|
|
1195
|
+
# If versioned query fails, check if it's a version-related error
|
|
1196
|
+
errors = data["errors"]
|
|
1197
|
+
if version_number is not None:
|
|
1198
|
+
error_msg = str(errors)
|
|
1199
|
+
# Check if the error suggests version isn't supported
|
|
1200
|
+
if "version" in error_msg.lower() and ("unknown" in error_msg.lower() or "cannot" in error_msg.lower()):
|
|
1201
|
+
# Properties API likely doesn't support version parameter yet
|
|
1202
|
+
# Fall through to try without version, but this means we'll get draft properties
|
|
1203
|
+
pass
|
|
1204
|
+
else:
|
|
1205
|
+
raise RuntimeError(data["errors"]) # Other errors should be raised
|
|
1206
|
+
else:
|
|
1207
|
+
raise RuntimeError(data["errors"]) # Draft queries should raise on error
|
|
1208
|
+
self._props_cache = data.get("data", {}).get("properties", []) or []
|
|
1209
|
+
self._props_loaded_at = time.time()
|
|
1210
|
+
return self._props_cache
|
|
1211
|
+
except RuntimeError:
|
|
1212
|
+
raise # Re-raise RuntimeErrors
|
|
1213
|
+
except Exception:
|
|
1214
|
+
# If all else fails, try searchProperties as last resort
|
|
1215
|
+
pass
|
|
1216
|
+
except Exception:
|
|
1217
|
+
# If fallback also fails, continue to searchProperties
|
|
1218
|
+
pass
|
|
1219
|
+
|
|
1220
|
+
# Attempt 3: searchProperties as last resort (doesn't support version or updatedAt/updatedBy)
|
|
1221
|
+
try:
|
|
1222
|
+
# Fallback to searchProperties
|
|
1223
|
+
q2_parsed = (
|
|
1224
|
+
"query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
1225
|
+
" searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
|
|
1226
|
+
" hits { id workspaceId productId itemId propertyType name readableId category displayUnit value parsedValue owner }\n"
|
|
1227
|
+
" }\n"
|
|
1228
|
+
"}"
|
|
1229
|
+
)
|
|
1230
|
+
try:
|
|
1231
|
+
r2 = self._client._transport.graphql(q2_parsed, {"iid": self._id, "limit": 100, "offset": 0})
|
|
1232
|
+
r2.raise_for_status()
|
|
1233
|
+
data2 = r2.json()
|
|
1234
|
+
if "errors" in data2:
|
|
1235
|
+
raise RuntimeError(data2["errors"]) # try minimal
|
|
1236
|
+
self._props_cache = data2.get("data", {}).get("searchProperties", {}).get("hits", []) or []
|
|
1237
|
+
self._props_loaded_at = time.time()
|
|
1238
|
+
except Exception:
|
|
1239
|
+
q2_min = (
|
|
1240
|
+
"query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
1241
|
+
" searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
|
|
1242
|
+
" hits { id workspaceId productId itemId propertyType name readableId category displayUnit value owner }\n"
|
|
1243
|
+
" }\n"
|
|
1244
|
+
"}"
|
|
1245
|
+
)
|
|
1246
|
+
r3 = self._client._transport.graphql(q2_min, {"iid": self._id, "limit": 100, "offset": 0})
|
|
1247
|
+
r3.raise_for_status()
|
|
1248
|
+
data3 = r3.json()
|
|
1249
|
+
if "errors" in data3:
|
|
1250
|
+
raise RuntimeError(data3["errors"]) # propagate
|
|
1251
|
+
self._props_cache = data3.get("data", {}).get("searchProperties", {}).get("hits", []) or []
|
|
1252
|
+
self._props_loaded_at = time.time()
|
|
1253
|
+
except Exception:
|
|
1254
|
+
# If all queries fail, return empty list
|
|
1255
|
+
self._props_cache = []
|
|
1256
|
+
self._props_loaded_at = time.time()
|
|
1257
|
+
return self._props_cache
|
|
1258
|
+
|
|
1259
|
+
def _props_key_map(self) -> Dict[str, Dict[str, Any]]:
|
|
1260
|
+
"""Map safe keys to property wrappers for item-level attribute access."""
|
|
1261
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
1262
|
+
if self._level != "item":
|
|
1263
|
+
return out
|
|
1264
|
+
props = self._properties()
|
|
1265
|
+
used_names: Dict[str, int] = {}
|
|
1266
|
+
for i, pr in enumerate(props):
|
|
1267
|
+
# Try to get name from various possible fields
|
|
1268
|
+
display = pr.get("readableId") or pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
|
|
1269
|
+
safe = _safe_key(str(display))
|
|
1270
|
+
|
|
1271
|
+
# Handle duplicate names by adding a suffix
|
|
1272
|
+
if safe in used_names:
|
|
1273
|
+
used_names[safe] += 1
|
|
1274
|
+
safe = f"{safe}_{used_names[safe]}"
|
|
1275
|
+
else:
|
|
1276
|
+
used_names[safe] = 0
|
|
1277
|
+
|
|
1278
|
+
out[safe] = _PropWrapper(pr, client=self._client)
|
|
1279
|
+
return out
|
|
1280
|
+
|
|
1281
|
+
def _load_children(self) -> None:
|
|
1282
|
+
if self._level == "root":
|
|
1283
|
+
rows = self._client.workspaces.list(limit=200, offset=0)
|
|
1284
|
+
for w in rows:
|
|
1285
|
+
display = w.get("readableId") or w.get("name") or str(w.get("id"))
|
|
1286
|
+
nm = _safe_key(display)
|
|
1287
|
+
child = _Node(self._client, "workspace", self, w["id"], display)
|
|
1288
|
+
child._cache_ttl = self._cache_ttl
|
|
1289
|
+
self._children_cache[nm] = child
|
|
1290
|
+
elif self._level == "workspace":
|
|
1291
|
+
page = self._client.products.list_by_workspace(workspace_id=self._id, limit=200, offset=0)
|
|
1292
|
+
for p in page.data:
|
|
1293
|
+
display = p.readableId or p.name or str(p.id)
|
|
1294
|
+
nm = _safe_key(display)
|
|
1295
|
+
# Propagate baseline_version_number from Product model onto product node
|
|
1296
|
+
child = _Node(
|
|
1297
|
+
self._client,
|
|
1298
|
+
"product",
|
|
1299
|
+
self,
|
|
1300
|
+
p.id,
|
|
1301
|
+
display,
|
|
1302
|
+
baseline_version_number=getattr(p, "baseline_version_number", None),
|
|
1303
|
+
)
|
|
1304
|
+
child._cache_ttl = self._cache_ttl
|
|
1305
|
+
self._children_cache[nm] = child
|
|
1306
|
+
elif self._level == "product":
|
|
1307
|
+
# Load items from baseline version for autocomplete suggestions.
|
|
1308
|
+
# Baseline semantics:
|
|
1309
|
+
# - Prefer configured baseline_version_number from the product model
|
|
1310
|
+
# - Otherwise, use latest version (highest version_number)
|
|
1311
|
+
# Clear cache first to ensure we always load fresh data from baseline
|
|
1312
|
+
self._children_cache.clear()
|
|
1313
|
+
|
|
1314
|
+
try:
|
|
1315
|
+
# Prefer configured baseline version if available
|
|
1316
|
+
version_number: Optional[int] = getattr(self, "_baseline_version_number", None)
|
|
1317
|
+
if version_number is None:
|
|
1318
|
+
page = self._client.products.list_product_versions(product_id=self._id, limit=100, offset=0)
|
|
1319
|
+
versions = getattr(page, "data", []) or []
|
|
1320
|
+
if versions:
|
|
1321
|
+
# Get the latest version (highest version_number)
|
|
1322
|
+
latest_version = max(versions, key=lambda v: getattr(v, "version_number", 0))
|
|
1323
|
+
version_number = getattr(latest_version, "version_number", None)
|
|
1324
|
+
if version_number is not None:
|
|
1325
|
+
# Load items from the chosen baseline/latest version
|
|
1326
|
+
rows = self._client.versions.list_items(
|
|
1327
|
+
product_id=self._id,
|
|
1328
|
+
version_number=version_number,
|
|
1329
|
+
limit=1000,
|
|
1330
|
+
offset=0,
|
|
1331
|
+
)
|
|
1332
|
+
for it in rows:
|
|
1333
|
+
if it.get("parentId") is None:
|
|
1334
|
+
display = it.get("readableId") or it.get("name") or str(it["id"])
|
|
1335
|
+
nm = _safe_key(display)
|
|
1336
|
+
child = _Node(self._client, "item", self, it["id"], display, version_number=version_number)
|
|
1337
|
+
child._cache_ttl = self._cache_ttl
|
|
1338
|
+
self._children_cache[nm] = child
|
|
1339
|
+
# Mark cache as fresh after successful baseline load
|
|
1340
|
+
self._children_loaded_at = time.time()
|
|
1341
|
+
return # Successfully loaded baseline items
|
|
1342
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
1343
|
+
# Only catch specific exceptions that might occur during data access
|
|
1344
|
+
pass # Fall through to draft if baseline loading fails
|
|
1345
|
+
except Exception:
|
|
1346
|
+
# For other exceptions (like network errors), still fall through to draft
|
|
1347
|
+
pass
|
|
1348
|
+
|
|
1349
|
+
# Fallback: load draft items if no versions exist or baseline loading failed
|
|
1350
|
+
# Only load draft if we haven't already loaded baseline items
|
|
1351
|
+
if not self._children_cache:
|
|
1352
|
+
rows = self._client.items.list_by_product(product_id=self._id, limit=1000, offset=0)
|
|
1353
|
+
for it in rows:
|
|
1354
|
+
if it.get("parentId") is None:
|
|
1355
|
+
display = it.get("readableId") or it.get("name") or str(it["id"])
|
|
1356
|
+
nm = _safe_key(display)
|
|
1357
|
+
child = _Node(self._client, "item", self, it["id"], display)
|
|
1358
|
+
child._cache_ttl = self._cache_ttl
|
|
1359
|
+
self._children_cache[nm] = child
|
|
1360
|
+
elif self._level == "version":
|
|
1361
|
+
# Fetch top-level items for this specific product version (or draft if version_number is None).
|
|
1362
|
+
anc = self
|
|
1363
|
+
pid: Optional[str] = None
|
|
1364
|
+
while anc is not None:
|
|
1365
|
+
if anc._level == "product":
|
|
1366
|
+
pid = anc._id
|
|
1367
|
+
break
|
|
1368
|
+
anc = anc._parent # type: ignore[assignment]
|
|
1369
|
+
if not pid:
|
|
1370
|
+
return
|
|
1371
|
+
try:
|
|
1372
|
+
version_number = int(self._id) if self._id is not None else None
|
|
1373
|
+
except (TypeError, ValueError):
|
|
1374
|
+
version_number = None
|
|
1375
|
+
|
|
1376
|
+
if version_number is None:
|
|
1377
|
+
# Draft: load items without version number
|
|
1378
|
+
rows = self._client.items.list_by_product(product_id=pid, limit=1000, offset=0)
|
|
1379
|
+
else:
|
|
1380
|
+
# Versioned: load items for specific version
|
|
1381
|
+
rows = self._client.versions.list_items(
|
|
1382
|
+
product_id=pid,
|
|
1383
|
+
version_number=version_number,
|
|
1384
|
+
limit=1000,
|
|
1385
|
+
offset=0,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
for it in rows:
|
|
1389
|
+
if it.get("parentId") is None:
|
|
1390
|
+
display = it.get("readableId") or it.get("name") or str(it["id"])
|
|
1391
|
+
nm = _safe_key(display)
|
|
1392
|
+
child = _Node(self._client, "item", self, it["id"], display, version_number=version_number)
|
|
1393
|
+
child._cache_ttl = self._cache_ttl
|
|
1394
|
+
self._children_cache[nm] = child
|
|
1395
|
+
elif self._level == "item":
|
|
1396
|
+
# Fetch children items by parent; derive productId from ancestor product
|
|
1397
|
+
anc = self
|
|
1398
|
+
pid: Optional[str] = None
|
|
1399
|
+
while anc is not None:
|
|
1400
|
+
if anc._level == "product":
|
|
1401
|
+
pid = anc._id
|
|
1402
|
+
break
|
|
1403
|
+
anc = anc._parent # type: ignore[assignment]
|
|
1404
|
+
if not pid:
|
|
1405
|
+
return
|
|
1406
|
+
|
|
1407
|
+
# Use version context if this item came from a version
|
|
1408
|
+
version_number = getattr(self, "_version_number", None)
|
|
1409
|
+
|
|
1410
|
+
if version_number is not None:
|
|
1411
|
+
# Load child items from versioned API
|
|
1412
|
+
all_items = self._client.versions.list_items(
|
|
1413
|
+
product_id=pid,
|
|
1414
|
+
version_number=version_number,
|
|
1415
|
+
limit=1000,
|
|
1416
|
+
offset=0,
|
|
1417
|
+
)
|
|
1418
|
+
# Filter to children of this item
|
|
1419
|
+
rows = [it for it in all_items if it.get("parentId") == self._id]
|
|
1420
|
+
else:
|
|
1421
|
+
# Draft: use regular items query
|
|
1422
|
+
q = (
|
|
1423
|
+
"query($pid: ID!, $parent: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
1424
|
+
" items(productId: $pid, parentItemId: $parent, limit: $limit, offset: $offset) { id name readableId productId parentId owner position }\n"
|
|
1425
|
+
"}"
|
|
1426
|
+
)
|
|
1427
|
+
r = self._client._transport.graphql(q, {"pid": pid, "parent": self._id, "limit": 1000, "offset": 0})
|
|
1428
|
+
r.raise_for_status()
|
|
1429
|
+
data = r.json()
|
|
1430
|
+
if "errors" in data:
|
|
1431
|
+
raise RuntimeError(data["errors"]) # surface
|
|
1432
|
+
rows = data.get("data", {}).get("items", []) or []
|
|
1433
|
+
|
|
1434
|
+
for it2 in rows:
|
|
1435
|
+
# Skip the current item (GraphQL returns parent + direct children)
|
|
1436
|
+
if str(it2.get("id")) == str(self._id):
|
|
1437
|
+
continue
|
|
1438
|
+
display = it2.get("readableId") or it2.get("name") or str(it2["id"])
|
|
1439
|
+
nm = _safe_key(display)
|
|
1440
|
+
child = _Node(self._client, "item", self, it2["id"], display, version_number=version_number)
|
|
1441
|
+
child._cache_ttl = self._cache_ttl
|
|
1442
|
+
self._children_cache[nm] = child
|
|
1443
|
+
|
|
1444
|
+
# Mark cache as fresh
|
|
1445
|
+
self._children_loaded_at = time.time()
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
class Browser:
|
|
1449
|
+
"""Public browser entrypoint."""
|
|
1450
|
+
|
|
1451
|
+
def __init__(self, client: Any, cache_ttl: float = 30.0) -> None:
|
|
1452
|
+
"""Initialize browser with optional cache TTL.
|
|
1453
|
+
|
|
1454
|
+
Args:
|
|
1455
|
+
client: PoelisClient instance
|
|
1456
|
+
cache_ttl: Cache time-to-live in seconds (default: 30)
|
|
1457
|
+
"""
|
|
1458
|
+
self._root = _Node(client, "root", None, None, None)
|
|
1459
|
+
# Set cache TTL for all nodes
|
|
1460
|
+
self._root._cache_ttl = cache_ttl
|
|
1461
|
+
# Best-effort: auto-enable curated completion in interactive shells
|
|
1462
|
+
global _AUTO_COMPLETER_INSTALLED
|
|
1463
|
+
if not _AUTO_COMPLETER_INSTALLED:
|
|
1464
|
+
try:
|
|
1465
|
+
if enable_dynamic_completion():
|
|
1466
|
+
_AUTO_COMPLETER_INSTALLED = True
|
|
1467
|
+
except Exception:
|
|
1468
|
+
# Non-interactive or IPython not available; ignore silently
|
|
1469
|
+
pass
|
|
1470
|
+
|
|
1471
|
+
def __getattr__(self, attr: str) -> Any: # pragma: no cover - notebook UX
|
|
1472
|
+
return getattr(self._root, attr)
|
|
1473
|
+
|
|
1474
|
+
def __repr__(self) -> str: # pragma: no cover - notebook UX
|
|
1475
|
+
org_context = get_organization_context_message(None)
|
|
1476
|
+
return f"<browser root> ({org_context})"
|
|
1477
|
+
|
|
1478
|
+
def __getitem__(self, key: str) -> Any: # pragma: no cover - notebook UX
|
|
1479
|
+
"""Delegate index-based access to the root node so names work: browser["Workspace Name"]."""
|
|
1480
|
+
return self._root[key]
|
|
1481
|
+
|
|
1482
|
+
def __dir__(self) -> list[str]: # pragma: no cover - notebook UX
|
|
1483
|
+
# Performance optimization: only load children if cache is stale or empty
|
|
1484
|
+
if self._root._is_children_cache_stale():
|
|
1485
|
+
self._root._load_children()
|
|
1486
|
+
keys = [*self._root._children_cache.keys(), "list_workspaces"]
|
|
1487
|
+
return sorted(keys)
|
|
1488
|
+
|
|
1489
|
+
def _names(self) -> List[str]:
|
|
1490
|
+
"""Return display names of root-level children (workspaces)."""
|
|
1491
|
+
return self._root._names()
|
|
1492
|
+
|
|
1493
|
+
# keep suggest internal so it doesn't appear in help/dir
|
|
1494
|
+
def _suggest(self) -> List[str]:
|
|
1495
|
+
sugg = list(self._root._suggest())
|
|
1496
|
+
sugg.append("list_workspaces")
|
|
1497
|
+
return sorted(set(sugg))
|
|
1498
|
+
|
|
1499
|
+
# suggest() removed from public API; dynamic completion still uses internal _suggest
|
|
1500
|
+
|
|
1501
|
+
def list_workspaces(self) -> "_NodeList":
|
|
1502
|
+
"""Return workspaces as a list-like object with `.names`."""
|
|
1503
|
+
return self._root._list_workspaces()
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def _safe_key(name: str) -> str:
|
|
1507
|
+
"""Convert arbitrary display name to a safe attribute key (letters/digits/_)."""
|
|
1508
|
+
key = re.sub(r"[^0-9a-zA-Z_]+", "_", name)
|
|
1509
|
+
key = key.strip("_")
|
|
1510
|
+
return key or "_"
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
class _PropsNode:
|
|
1514
|
+
"""Pseudo-node that exposes item properties as child attributes by display name.
|
|
1515
|
+
|
|
1516
|
+
Usage: item.props.<Property_Name> or item.props["Property Name"].
|
|
1517
|
+
Returns the raw property dictionaries from GraphQL.
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
def __init__(self, item_node: _Node) -> None:
|
|
1521
|
+
self._item = item_node
|
|
1522
|
+
self._children_cache: Dict[str, _PropWrapper] = {}
|
|
1523
|
+
self._names: List[str] = []
|
|
1524
|
+
self._loaded_at: Optional[float] = None
|
|
1525
|
+
self._cache_ttl: float = item_node._cache_ttl # Inherit cache TTL from parent node
|
|
1526
|
+
|
|
1527
|
+
def __repr__(self) -> str: # pragma: no cover - notebook UX
|
|
1528
|
+
return f"<props of {self._item.name or self._item.id}>"
|
|
1529
|
+
|
|
1530
|
+
def _ensure_loaded(self) -> None:
|
|
1531
|
+
# Performance optimization: only load if cache is stale or empty
|
|
1532
|
+
if self._children_cache and self._loaded_at is not None:
|
|
1533
|
+
if time.time() - self._loaded_at <= self._cache_ttl:
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
props = self._item._properties()
|
|
1537
|
+
used_names: Dict[str, int] = {}
|
|
1538
|
+
names_list = []
|
|
1539
|
+
for i, pr in enumerate(props):
|
|
1540
|
+
# Try to get name from various possible fields
|
|
1541
|
+
display = pr.get("readableId") or pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
|
|
1542
|
+
safe = _safe_key(str(display))
|
|
1543
|
+
|
|
1544
|
+
# Handle duplicate names by adding a suffix
|
|
1545
|
+
if safe in used_names:
|
|
1546
|
+
used_names[safe] += 1
|
|
1547
|
+
safe = f"{safe}_{used_names[safe]}"
|
|
1548
|
+
else:
|
|
1549
|
+
used_names[safe] = 0
|
|
1550
|
+
|
|
1551
|
+
self._children_cache[safe] = _PropWrapper(pr, client=self._item._client)
|
|
1552
|
+
names_list.append(display)
|
|
1553
|
+
self._names = names_list
|
|
1554
|
+
self._loaded_at = time.time()
|
|
1555
|
+
|
|
1556
|
+
def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
|
|
1557
|
+
self._ensure_loaded()
|
|
1558
|
+
return sorted(list(self._children_cache.keys()))
|
|
1559
|
+
|
|
1560
|
+
# names() removed; use item.list_properties().names instead
|
|
1561
|
+
|
|
1562
|
+
def __getattr__(self, attr: str) -> Any:
|
|
1563
|
+
self._ensure_loaded()
|
|
1564
|
+
if attr in self._children_cache:
|
|
1565
|
+
prop_wrapper = self._children_cache[attr]
|
|
1566
|
+
# Track accessed properties for deletion detection
|
|
1567
|
+
if self._item._client is not None:
|
|
1568
|
+
try:
|
|
1569
|
+
change_tracker = getattr(self._item._client, "_change_tracker", None)
|
|
1570
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1571
|
+
property_path = self._item._build_path(attr)
|
|
1572
|
+
if property_path:
|
|
1573
|
+
prop_name = (
|
|
1574
|
+
getattr(prop_wrapper, "_raw", {}).get("readableId")
|
|
1575
|
+
or getattr(prop_wrapper, "_raw", {}).get("name")
|
|
1576
|
+
or attr
|
|
1577
|
+
)
|
|
1578
|
+
prop_id = getattr(prop_wrapper, "_raw", {}).get("id")
|
|
1579
|
+
change_tracker.record_accessed_property(property_path, prop_name, prop_id)
|
|
1580
|
+
except Exception:
|
|
1581
|
+
pass # Silently ignore tracking errors
|
|
1582
|
+
return prop_wrapper
|
|
1583
|
+
|
|
1584
|
+
# Check if property was previously accessed (deletion detection)
|
|
1585
|
+
if self._item._client is not None:
|
|
1586
|
+
try:
|
|
1587
|
+
change_tracker = getattr(self._item._client, "_change_tracker", None)
|
|
1588
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1589
|
+
property_path = self._item._build_path(attr)
|
|
1590
|
+
if property_path:
|
|
1591
|
+
change_tracker.warn_if_deleted(property_path=property_path)
|
|
1592
|
+
except Exception:
|
|
1593
|
+
pass # Silently ignore tracking errors
|
|
1594
|
+
|
|
1595
|
+
raise AttributeError(attr)
|
|
1596
|
+
|
|
1597
|
+
def __getitem__(self, key: str) -> Any:
|
|
1598
|
+
self._ensure_loaded()
|
|
1599
|
+
if key in self._children_cache:
|
|
1600
|
+
return self._children_cache[key]
|
|
1601
|
+
# match by display name
|
|
1602
|
+
for safe, data in self._children_cache.items():
|
|
1603
|
+
try:
|
|
1604
|
+
raw = getattr(data, "_raw", {})
|
|
1605
|
+
if raw.get("readableId") == key or raw.get("name") == key: # type: ignore[arg-type]
|
|
1606
|
+
return data
|
|
1607
|
+
except Exception:
|
|
1608
|
+
continue
|
|
1609
|
+
safe = _safe_key(key)
|
|
1610
|
+
if safe in self._children_cache:
|
|
1611
|
+
return self._children_cache[safe]
|
|
1612
|
+
raise KeyError(key)
|
|
1613
|
+
|
|
1614
|
+
# keep suggest internal so it doesn't appear in help/dir
|
|
1615
|
+
def _suggest(self) -> List[str]:
|
|
1616
|
+
self._ensure_loaded()
|
|
1617
|
+
return sorted(list(self._children_cache.keys()))
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
class _NodeList:
|
|
1621
|
+
"""Lightweight sequence wrapper for node/property lists with `.names`.
|
|
1622
|
+
|
|
1623
|
+
Provides iteration and index access to underlying items, plus a `.names`
|
|
1624
|
+
attribute returning the display names in the same order.
|
|
1625
|
+
"""
|
|
1626
|
+
|
|
1627
|
+
def __init__(self, items: List[Any], names: List[str]) -> None:
|
|
1628
|
+
self._items = list(items)
|
|
1629
|
+
self._names = list(names)
|
|
1630
|
+
|
|
1631
|
+
def __iter__(self): # pragma: no cover - trivial
|
|
1632
|
+
return iter(self._items)
|
|
1633
|
+
|
|
1634
|
+
def __len__(self) -> int: # pragma: no cover - trivial
|
|
1635
|
+
return len(self._items)
|
|
1636
|
+
|
|
1637
|
+
def __getitem__(self, idx: int) -> Any: # pragma: no cover - trivial
|
|
1638
|
+
return self._items[idx]
|
|
1639
|
+
|
|
1640
|
+
@property
|
|
1641
|
+
def names(self) -> List[str]:
|
|
1642
|
+
return list(self._names)
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
class _PropWrapper:
|
|
1646
|
+
"""Lightweight accessor for a property dict, exposing `.value` and `.raw`.
|
|
1647
|
+
|
|
1648
|
+
Normalizes different property result shapes (union vs search) into `.value`.
|
|
1649
|
+
"""
|
|
1650
|
+
|
|
1651
|
+
def __init__(self, prop: Dict[str, Any], client: Any = None) -> None:
|
|
1652
|
+
"""Initialize property wrapper.
|
|
1653
|
+
|
|
1654
|
+
Args:
|
|
1655
|
+
prop: Property dictionary from GraphQL.
|
|
1656
|
+
client: Optional PoelisClient instance for change tracking.
|
|
1657
|
+
"""
|
|
1658
|
+
self._raw = prop
|
|
1659
|
+
self._client = client
|
|
1660
|
+
|
|
1661
|
+
def _get_property_value(self) -> Any:
|
|
1662
|
+
"""Extract and parse the property value from raw data.
|
|
1663
|
+
|
|
1664
|
+
Returns:
|
|
1665
|
+
Any: The parsed property value.
|
|
1666
|
+
"""
|
|
1667
|
+
p = self._raw
|
|
1668
|
+
# Use parsedValue if available and not None (new backend feature)
|
|
1669
|
+
if "parsedValue" in p:
|
|
1670
|
+
parsed_val = p.get("parsedValue")
|
|
1671
|
+
if parsed_val is not None:
|
|
1672
|
+
# Recursively parse arrays/matrices that might contain string numbers
|
|
1673
|
+
return self._parse_nested_value(parsed_val)
|
|
1674
|
+
# Fallback to legacy parsing logic for backward compatibility
|
|
1675
|
+
# searchProperties shape
|
|
1676
|
+
if "numericValue" in p and p.get("numericValue") is not None:
|
|
1677
|
+
return p["numericValue"]
|
|
1678
|
+
if "textValue" in p and p.get("textValue") is not None:
|
|
1679
|
+
return p["textValue"]
|
|
1680
|
+
if "dateValue" in p and p.get("dateValue") is not None:
|
|
1681
|
+
return p["dateValue"]
|
|
1682
|
+
# union shape
|
|
1683
|
+
if "integerPart" in p:
|
|
1684
|
+
integer_part = p.get("integerPart")
|
|
1685
|
+
exponent = p.get("exponent", 0) or 0
|
|
1686
|
+
try:
|
|
1687
|
+
return (integer_part or 0) * (10 ** int(exponent))
|
|
1688
|
+
except Exception:
|
|
1689
|
+
return integer_part
|
|
1690
|
+
# If parsedValue was None or missing, try to parse the raw value for numeric properties
|
|
1691
|
+
if "value" in p:
|
|
1692
|
+
raw_value = p.get("value")
|
|
1693
|
+
# Check if this is a numeric property and try to parse the string
|
|
1694
|
+
property_type = (p.get("__typename") or p.get("propertyType") or "").lower()
|
|
1695
|
+
is_numeric = property_type in ("numericproperty", "numeric")
|
|
1696
|
+
# If it's a numeric property, try to parse the string as a number
|
|
1697
|
+
if isinstance(raw_value, str) and is_numeric:
|
|
1698
|
+
try:
|
|
1699
|
+
# Try to parse as float first (handles decimals), then int
|
|
1700
|
+
parsed = float(raw_value)
|
|
1701
|
+
# Return int if it's a whole number, otherwise float
|
|
1702
|
+
return int(parsed) if parsed.is_integer() else parsed
|
|
1703
|
+
except (ValueError, TypeError):
|
|
1704
|
+
# If parsing fails, return the raw string
|
|
1705
|
+
return raw_value
|
|
1706
|
+
return raw_value
|
|
1707
|
+
return None
|
|
1708
|
+
|
|
1709
|
+
@property
|
|
1710
|
+
def value(self) -> Any: # type: ignore[override]
|
|
1711
|
+
"""Get the property value, with change detection if enabled.
|
|
1712
|
+
|
|
1713
|
+
Returns:
|
|
1714
|
+
Any: The property value.
|
|
1715
|
+
"""
|
|
1716
|
+
current_value = self._get_property_value()
|
|
1717
|
+
|
|
1718
|
+
# Check for backend-side changes if client is available and change
|
|
1719
|
+
# detection is enabled. This compares the current value to the
|
|
1720
|
+
# persisted baseline across runs.
|
|
1721
|
+
if self._client is not None:
|
|
1722
|
+
try:
|
|
1723
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1724
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1725
|
+
# Skip tracking for versioned properties (they're immutable)
|
|
1726
|
+
# Versioned properties have productVersionNumber set
|
|
1727
|
+
if self._raw.get("productVersionNumber") is not None:
|
|
1728
|
+
return current_value
|
|
1729
|
+
|
|
1730
|
+
# Get property ID for tracking
|
|
1731
|
+
property_id = self._raw.get("id")
|
|
1732
|
+
if property_id:
|
|
1733
|
+
# Get property name for warning message
|
|
1734
|
+
prop_name = (
|
|
1735
|
+
self._raw.get("readableId")
|
|
1736
|
+
or self._raw.get("name")
|
|
1737
|
+
or self._raw.get("id")
|
|
1738
|
+
)
|
|
1739
|
+
# Get updatedAt and updatedBy if available (from sdkProperties)
|
|
1740
|
+
updated_at = self._raw.get("updatedAt")
|
|
1741
|
+
updated_by = self._raw.get("updatedBy")
|
|
1742
|
+
# Check and warn if changed; path will be inferred from
|
|
1743
|
+
# previously recorded accessed_properties when possible.
|
|
1744
|
+
change_tracker.warn_if_changed(
|
|
1745
|
+
property_id=property_id,
|
|
1746
|
+
current_value=current_value,
|
|
1747
|
+
name=prop_name,
|
|
1748
|
+
updated_at=updated_at,
|
|
1749
|
+
updated_by=updated_by,
|
|
1750
|
+
)
|
|
1751
|
+
except Exception:
|
|
1752
|
+
# Silently ignore errors in change tracking to avoid breaking property access
|
|
1753
|
+
pass
|
|
1754
|
+
|
|
1755
|
+
return current_value
|
|
1756
|
+
|
|
1757
|
+
@value.setter
|
|
1758
|
+
def value(self, new_value: Any) -> None:
|
|
1759
|
+
"""Set the property value and emit a local change warning if enabled.
|
|
1760
|
+
|
|
1761
|
+
This is primarily intended for notebook/script usage, e.g.::
|
|
1762
|
+
|
|
1763
|
+
mass = ws.demo_product.draft.get_property("demo_property_mass")
|
|
1764
|
+
mass.value = 123.4
|
|
1765
|
+
|
|
1766
|
+
The setter updates the in-memory value and asks the ``PropertyChangeTracker``
|
|
1767
|
+
to emit a warning and log entry for this local edit. It does not push the
|
|
1768
|
+
change back to the Poelis backend.
|
|
1769
|
+
"""
|
|
1770
|
+
old_value = self._get_property_value()
|
|
1771
|
+
|
|
1772
|
+
# If the value did not actually change, do nothing.
|
|
1773
|
+
if old_value == new_value:
|
|
1774
|
+
return
|
|
1775
|
+
|
|
1776
|
+
# Update the raw payload with the new value. We prefer the canonical
|
|
1777
|
+
# "value" field when present; for legacy shapes we still populate it so
|
|
1778
|
+
# subsequent reads see the edited value.
|
|
1779
|
+
try:
|
|
1780
|
+
self._raw["value"] = new_value
|
|
1781
|
+
except Exception:
|
|
1782
|
+
# If raw is not a standard dict-like, best-effort: ignore.
|
|
1783
|
+
pass
|
|
1784
|
+
|
|
1785
|
+
# Emit a local edit warning through the change tracker when available.
|
|
1786
|
+
if self._client is not None:
|
|
1787
|
+
try:
|
|
1788
|
+
change_tracker = getattr(self._client, "_change_tracker", None)
|
|
1789
|
+
if change_tracker is not None and change_tracker.is_enabled():
|
|
1790
|
+
property_id = self._raw.get("id")
|
|
1791
|
+
prop_name = (
|
|
1792
|
+
self._raw.get("readableId")
|
|
1793
|
+
or self._raw.get("name")
|
|
1794
|
+
or self._raw.get("id")
|
|
1795
|
+
)
|
|
1796
|
+
change_tracker.warn_on_local_edit(
|
|
1797
|
+
property_id=property_id,
|
|
1798
|
+
old_value=old_value,
|
|
1799
|
+
new_value=new_value,
|
|
1800
|
+
name=prop_name,
|
|
1801
|
+
)
|
|
1802
|
+
except Exception:
|
|
1803
|
+
# Silently ignore tracking errors; setting the value itself should not fail.
|
|
1804
|
+
pass
|
|
1805
|
+
|
|
1806
|
+
def _parse_nested_value(self, value: Any) -> Any:
|
|
1807
|
+
"""Recursively parse nested lists/arrays that might contain string numbers."""
|
|
1808
|
+
if isinstance(value, list):
|
|
1809
|
+
return [self._parse_nested_value(item) for item in value]
|
|
1810
|
+
elif isinstance(value, str):
|
|
1811
|
+
# Try to parse string as number if it looks numeric
|
|
1812
|
+
if self._looks_like_number(value):
|
|
1813
|
+
try:
|
|
1814
|
+
parsed = float(value)
|
|
1815
|
+
return int(parsed) if parsed.is_integer() else parsed
|
|
1816
|
+
except (ValueError, TypeError):
|
|
1817
|
+
return value
|
|
1818
|
+
return value
|
|
1819
|
+
else:
|
|
1820
|
+
# Already a number or other type, return as-is
|
|
1821
|
+
return value
|
|
1822
|
+
|
|
1823
|
+
def _looks_like_number(self, value: str) -> bool:
|
|
1824
|
+
"""Check if a string value looks like a numeric value."""
|
|
1825
|
+
if not isinstance(value, str):
|
|
1826
|
+
return False
|
|
1827
|
+
value = value.strip()
|
|
1828
|
+
if not value:
|
|
1829
|
+
return False
|
|
1830
|
+
# Allow optional leading sign, digits, optional decimal point, optional exponent
|
|
1831
|
+
# This matches patterns like: "123", "-45.67", "1.23e-4", "+100"
|
|
1832
|
+
try:
|
|
1833
|
+
float(value)
|
|
1834
|
+
return True
|
|
1835
|
+
except ValueError:
|
|
1836
|
+
return False
|
|
1837
|
+
|
|
1838
|
+
@property
|
|
1839
|
+
def category(self) -> Optional[str]:
|
|
1840
|
+
"""Return the category for this property.
|
|
1841
|
+
|
|
1842
|
+
Note: Category values are normalized/canonicalized by the backend.
|
|
1843
|
+
Values may be upper-cased and some previously distinct categories
|
|
1844
|
+
may have been merged into canonical forms.
|
|
1845
|
+
|
|
1846
|
+
Returns:
|
|
1847
|
+
Optional[str]: The category string, or None if not available.
|
|
1848
|
+
"""
|
|
1849
|
+
p = self._raw
|
|
1850
|
+
cat = p.get("category")
|
|
1851
|
+
return str(cat) if cat is not None else None
|
|
1852
|
+
|
|
1853
|
+
@property
|
|
1854
|
+
def unit(self) -> Optional[str]:
|
|
1855
|
+
"""Return the display unit for this property.
|
|
1856
|
+
|
|
1857
|
+
Returns:
|
|
1858
|
+
Optional[str]: The unit string (e.g., "kg", "°C"), or None if not available.
|
|
1859
|
+
"""
|
|
1860
|
+
p = self._raw
|
|
1861
|
+
unit = p.get("displayUnit") or p.get("display_unit")
|
|
1862
|
+
return str(unit) if unit is not None else None
|
|
1863
|
+
|
|
1864
|
+
@property
|
|
1865
|
+
def name(self) -> Optional[str]:
|
|
1866
|
+
"""Return the best-effort display name for this property.
|
|
1867
|
+
|
|
1868
|
+
Falls back to name, id, or category when readableId is not present.
|
|
1869
|
+
"""
|
|
1870
|
+
p = self._raw
|
|
1871
|
+
n = p.get("readableId") or p.get("name") or p.get("id") or p.get("category")
|
|
1872
|
+
return str(n) if n is not None else None
|
|
1873
|
+
|
|
1874
|
+
def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
|
|
1875
|
+
# Expose only the minimal attributes for browsing
|
|
1876
|
+
return ["value", "category", "unit"]
|
|
1877
|
+
|
|
1878
|
+
def __repr__(self) -> str: # pragma: no cover - notebook UX
|
|
1879
|
+
name = self._raw.get("readableId") or self._raw.get("name") or self._raw.get("id")
|
|
1880
|
+
return f"<property {name}: {self.value}>"
|
|
1881
|
+
|
|
1882
|
+
def __str__(self) -> str: # pragma: no cover - notebook UX
|
|
1883
|
+
"""Return the display name for this property for string conversion.
|
|
1884
|
+
|
|
1885
|
+
This allows printing a property object directly (e.g., ``print(prop)``)
|
|
1886
|
+
and seeing its human-friendly name instead of the full representation.
|
|
1887
|
+
|
|
1888
|
+
Returns:
|
|
1889
|
+
str: The best-effort display name, or an empty string if unknown.
|
|
1890
|
+
"""
|
|
1891
|
+
return self.name or ""
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def enable_dynamic_completion() -> bool:
|
|
1896
|
+
"""Enable dynamic attribute completion in IPython/Jupyter environments.
|
|
1897
|
+
|
|
1898
|
+
This helper attempts to configure IPython to use runtime-based completion
|
|
1899
|
+
(disabling Jedi) so that our dynamic `__dir__` and `suggest()` methods are
|
|
1900
|
+
respected by TAB completion. Returns True if an interactive shell was found
|
|
1901
|
+
and configured, False otherwise.
|
|
1902
|
+
"""
|
|
1903
|
+
|
|
1904
|
+
try:
|
|
1905
|
+
# Deferred import to avoid hard dependency
|
|
1906
|
+
from IPython import get_ipython # type: ignore
|
|
1907
|
+
except Exception:
|
|
1908
|
+
return False
|
|
1909
|
+
|
|
1910
|
+
ip = None
|
|
1911
|
+
try:
|
|
1912
|
+
ip = get_ipython() # type: ignore[assignment]
|
|
1913
|
+
except Exception:
|
|
1914
|
+
ip = None
|
|
1915
|
+
if ip is None:
|
|
1916
|
+
return False
|
|
1917
|
+
|
|
1918
|
+
enabled = False
|
|
1919
|
+
# Best-effort configuration: rely on IPython's fallback (non-Jedi) completer
|
|
1920
|
+
try:
|
|
1921
|
+
if hasattr(ip, "Completer") and hasattr(ip.Completer, "use_jedi"):
|
|
1922
|
+
# Disable Jedi to let IPython consult __dir__ dynamically
|
|
1923
|
+
ip.Completer.use_jedi = False # type: ignore[assignment]
|
|
1924
|
+
# Greedy completion improves attribute completion depth
|
|
1925
|
+
if hasattr(ip.Completer, "greedy"):
|
|
1926
|
+
ip.Completer.greedy = True # type: ignore[assignment]
|
|
1927
|
+
enabled = True
|
|
1928
|
+
except Exception:
|
|
1929
|
+
pass
|
|
1930
|
+
|
|
1931
|
+
# Additionally, install a lightweight attribute completer that uses suggest()
|
|
1932
|
+
try:
|
|
1933
|
+
comp = getattr(ip, "Completer", None)
|
|
1934
|
+
if comp is not None and hasattr(comp, "attr_matches"):
|
|
1935
|
+
orig_attr_matches = comp.attr_matches # type: ignore[attr-defined]
|
|
1936
|
+
|
|
1937
|
+
def _poelis_attr_matches(self: Any, text: str) -> List[str]: # pragma: no cover - interactive behavior
|
|
1938
|
+
try:
|
|
1939
|
+
# text is like "client.browser.uh2.pr" → split at last dot
|
|
1940
|
+
obj_expr, _, prefix = text.rpartition(".")
|
|
1941
|
+
if not obj_expr:
|
|
1942
|
+
return orig_attr_matches(text) # type: ignore[operator]
|
|
1943
|
+
# Evaluate the object in the user namespace
|
|
1944
|
+
ns = getattr(self, "namespace", {})
|
|
1945
|
+
obj_val = eval(obj_expr, ns, ns)
|
|
1946
|
+
|
|
1947
|
+
# For Poelis browser objects, show ONLY our curated suggestions
|
|
1948
|
+
from_types = (Browser, _Node, _PropsNode, _PropWrapper)
|
|
1949
|
+
if isinstance(obj_val, from_types):
|
|
1950
|
+
# Build suggestion list
|
|
1951
|
+
if isinstance(obj_val, _PropWrapper):
|
|
1952
|
+
sugg: List[str] = ["value", "category", "unit"]
|
|
1953
|
+
elif hasattr(obj_val, "_suggest"):
|
|
1954
|
+
sugg = list(getattr(obj_val, "_suggest")()) # type: ignore[no-untyped-call]
|
|
1955
|
+
else:
|
|
1956
|
+
sugg = list(dir(obj_val))
|
|
1957
|
+
# Filter by prefix and format matches as full attribute paths
|
|
1958
|
+
out: List[str] = []
|
|
1959
|
+
for s in sugg:
|
|
1960
|
+
if not prefix or str(s).startswith(prefix):
|
|
1961
|
+
out.append(f"{obj_expr}.{s}")
|
|
1962
|
+
return out
|
|
1963
|
+
|
|
1964
|
+
# Otherwise, fall back to default behavior
|
|
1965
|
+
return orig_attr_matches(text) # type: ignore[operator]
|
|
1966
|
+
except Exception:
|
|
1967
|
+
# fall back to original on any error
|
|
1968
|
+
return orig_attr_matches(text) # type: ignore[operator]
|
|
1969
|
+
|
|
1970
|
+
comp.attr_matches = MethodType(_poelis_attr_matches, comp) # type: ignore[assignment]
|
|
1971
|
+
enabled = True
|
|
1972
|
+
except Exception:
|
|
1973
|
+
pass
|
|
1974
|
+
|
|
1975
|
+
# Also register as a high-priority matcher in IPCompleter.matchers
|
|
1976
|
+
try:
|
|
1977
|
+
comp = getattr(ip, "Completer", None)
|
|
1978
|
+
if comp is not None and hasattr(comp, "matchers") and not getattr(comp, "_poelis_matcher_installed", False):
|
|
1979
|
+
orig_attr_matches = comp.attr_matches # type: ignore[attr-defined]
|
|
1980
|
+
|
|
1981
|
+
def _poelis_matcher(self: Any, text: str) -> List[str]: # pragma: no cover - interactive behavior
|
|
1982
|
+
# Delegate to our attribute logic for dotted expressions; otherwise empty
|
|
1983
|
+
if "." in text:
|
|
1984
|
+
try:
|
|
1985
|
+
return self.attr_matches(text) # type: ignore[operator]
|
|
1986
|
+
except Exception:
|
|
1987
|
+
return orig_attr_matches(text) # type: ignore[operator]
|
|
1988
|
+
return []
|
|
1989
|
+
|
|
1990
|
+
# Prepend our matcher so it's consulted early
|
|
1991
|
+
comp.matchers.insert(0, MethodType(_poelis_matcher, comp)) # type: ignore[arg-type]
|
|
1992
|
+
setattr(comp, "_poelis_matcher_installed", True)
|
|
1993
|
+
enabled = True
|
|
1994
|
+
except Exception:
|
|
1995
|
+
pass
|
|
1996
|
+
|
|
1997
|
+
return bool(enabled)
|
|
1998
|
+
|