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/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
+