poelis-sdk 0.1.8__tar.gz → 0.2.0__tar.gz

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.

Files changed (32) hide show
  1. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/PKG-INFO +1 -18
  2. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/README.md +0 -17
  3. poelis_sdk-0.2.0/notebooks/try_poelis_sdk.ipynb +220 -0
  4. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/pyproject.toml +1 -1
  5. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/browser.py +202 -37
  6. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/items.py +3 -12
  7. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/search.py +3 -3
  8. poelis_sdk-0.2.0/tests/test_browser_navigation.py +130 -0
  9. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/uv.lock +1 -1
  10. poelis_sdk-0.1.8/notebooks/try_poelis_sdk.ipynb +0 -635
  11. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/.github/workflows/ci.yml +0 -0
  12. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/.github/workflows/codeql.yml +0 -0
  13. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/.github/workflows/publish-on-push.yml +0 -0
  14. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/.gitignore +0 -0
  15. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/LICENSE +0 -0
  16. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/__init__.py +0 -0
  17. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/_transport.py +0 -0
  18. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/auth0.py +0 -0
  19. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/client.py +0 -0
  20. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/exceptions.py +0 -0
  21. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/logging.py +0 -0
  22. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/models.py +0 -0
  23. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/org_validation.py +0 -0
  24. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/products.py +0 -0
  25. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/poelis_sdk/workspaces.py +0 -0
  26. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/tests/test_client_basic.py +0 -0
  27. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/tests/test_errors_and_backoff.py +0 -0
  28. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/tests/test_items_client.py +0 -0
  29. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/tests/test_search_client.py +0 -0
  30. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/src/tests/test_transport_and_products.py +0 -0
  31. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/tests/__init__.py +0 -0
  32. {poelis_sdk-0.1.8 → poelis_sdk-0.2.0}/tests/test_integration_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: poelis-sdk
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for Poelis
5
5
  Project-URL: Homepage, https://poelis.com
6
6
  Project-URL: Source, https://github.com/PoelisTechnologies/poelis-python-sdk
@@ -42,22 +42,6 @@ client = PoelisClient(
42
42
  api_key="poelis_live_A1B2C3...", # Organization Settings → API Keys
43
43
  org_id="tenant_uci_001", # same section
44
44
  )
45
-
46
- # Workspaces → Products
47
- workspaces = client.workspaces.list(limit=10, offset=0)
48
- ws_id = workspaces[0]["id"]
49
-
50
- page = client.products.list_by_workspace(workspace_id=ws_id, limit=10, offset=0)
51
- print([p.name for p in page.data])
52
-
53
- # Items for a product
54
- pid = page.data[0].id
55
- items = client.items.list_by_product(product_id=pid, limit=10, offset=0)
56
- print([i.get("name") for i in items])
57
-
58
- # Property search
59
- props = client.search.properties(q="*", workspace_id=ws_id, limit=10, offset=0)
60
- print(props["total"], len(props["hits"]))
61
45
  ```
62
46
 
63
47
  ## Configuration
@@ -73,7 +57,6 @@ print(props["total"], len(props["hits"]))
73
57
  ```bash
74
58
  export POELIS_API_KEY=poelis_live_A1B2C3...
75
59
  export POELIS_ORG_ID=tenant_id_001
76
- # POELIS_BASE_URL is optional - defaults to the managed GCP endpoint
77
60
  ```
78
61
 
79
62
 
@@ -19,22 +19,6 @@ client = PoelisClient(
19
19
  api_key="poelis_live_A1B2C3...", # Organization Settings → API Keys
20
20
  org_id="tenant_uci_001", # same section
21
21
  )
22
-
23
- # Workspaces → Products
24
- workspaces = client.workspaces.list(limit=10, offset=0)
25
- ws_id = workspaces[0]["id"]
26
-
27
- page = client.products.list_by_workspace(workspace_id=ws_id, limit=10, offset=0)
28
- print([p.name for p in page.data])
29
-
30
- # Items for a product
31
- pid = page.data[0].id
32
- items = client.items.list_by_product(product_id=pid, limit=10, offset=0)
33
- print([i.get("name") for i in items])
34
-
35
- # Property search
36
- props = client.search.properties(q="*", workspace_id=ws_id, limit=10, offset=0)
37
- print(props["total"], len(props["hits"]))
38
22
  ```
39
23
 
40
24
  ## Configuration
@@ -50,7 +34,6 @@ print(props["total"], len(props["hits"]))
50
34
  ```bash
51
35
  export POELIS_API_KEY=poelis_live_A1B2C3...
52
36
  export POELIS_ORG_ID=tenant_id_001
53
- # POELIS_BASE_URL is optional - defaults to the managed GCP endpoint
54
37
  ```
55
38
 
56
39
 
@@ -0,0 +1,220 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Poelis Python SDK Tutorial\n",
8
+ "\n",
9
+ "Welcome to the Poelis Python SDK tutorial! This notebook will guide you through setting up and using the SDK to interact with the Poelis API.\n",
10
+ "\n",
11
+ "## What is Poelis?\n",
12
+ "\n",
13
+ "Poelis is a platform for managing hierarchical data structures, products, items, and their properties. The Python SDK provides a convenient way to interact with the Poelis API from your Python applications.\n",
14
+ "\n",
15
+ "## Table of Contents\n",
16
+ "\n",
17
+ "1. [Installation](#Installation)\n",
18
+ "2. [Authentication Setup](#Authentication-Setup)\n",
19
+ "3. [Basic Client Setup](#Basic-Client-Setup)\n",
20
+ "4. [Browser Interface](#Browser-Interface)\n",
21
+ "\n"
22
+ ]
23
+ },
24
+ {
25
+ "cell_type": "markdown",
26
+ "metadata": {},
27
+ "source": [
28
+ "## 1. Installation\n",
29
+ "\n",
30
+ "First, let's install the Poelis Python SDK. The SDK requires Python 3.11 or higher.\n",
31
+ "\n",
32
+ "```bash\n",
33
+ "pip install -U poelis-sdk\n",
34
+ "```\n",
35
+ "\n",
36
+ "### Requirements\n",
37
+ "- Python >= 3.11\n",
38
+ "- Access to a Poelis API endpoint\n",
39
+ "- Valid API credentials (API key + organization ID)\n"
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "code",
44
+ "execution_count": null,
45
+ "metadata": {},
46
+ "outputs": [],
47
+ "source": [
48
+ "!pip install -U poelis-sdk"
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "metadata": {},
55
+ "outputs": [],
56
+ "source": [
57
+ "# Import the SDK\n",
58
+ "from poelis_sdk import PoelisClient\n",
59
+ "\n",
60
+ "# Check if the SDK is properly installed\n",
61
+ "print(\"Poelis SDK imported successfully!\")\n",
62
+ "print(f\"PoelisClient class: {PoelisClient}\")\n"
63
+ ]
64
+ },
65
+ {
66
+ "cell_type": "markdown",
67
+ "metadata": {},
68
+ "source": [
69
+ "## 2. Authentication Setup\n",
70
+ "\n",
71
+ "Before you can use the SDK, you need to obtain your API credentials from the Poelis web interface.\n",
72
+ "\n",
73
+ "### Getting Your Credentials\n",
74
+ "\n",
75
+ "1. **Navigate to Organization Settings → API Keys** in the Poelis web interface\n",
76
+ "2. **Create a new API key**:\n",
77
+ " - Choose a descriptive name\n",
78
+ " - Select appropriate scopes\n",
79
+ " - Copy the key immediately\n",
80
+ "3. **Note your Organization ID** (displayed in the same section)\n",
81
+ "\n",
82
+ "### Environment Variables (Recommended)\n",
83
+ "\n",
84
+ "For security, store your credentials as environment variables:\n",
85
+ "\n",
86
+ "```bash\n",
87
+ "export POELIS_API_KEY=poelis_live_A1B2C3...\n",
88
+ "export POELIS_ORG_ID=org_id_001\n",
89
+ "```\n"
90
+ ]
91
+ },
92
+ {
93
+ "cell_type": "markdown",
94
+ "metadata": {},
95
+ "source": [
96
+ "## 3. Basic Client Setup\n",
97
+ "\n",
98
+ "There are two ways to initialize the Poelis client:\n",
99
+ "\n",
100
+ "### Method 1: Direct Initialization (Simplified)\n",
101
+ "\n",
102
+ "```python\n",
103
+ "# Simple usage with defaults\n",
104
+ "client = PoelisClient(\n",
105
+ " api_key=\"poelis_live_A1B2C3...\",\n",
106
+ " org_id=\"tenant_id_001\"\n",
107
+ ")\n",
108
+ "```\n",
109
+ "\n",
110
+ "### Method 2: From Environment Variables (Recommended)\n",
111
+ "\n",
112
+ "```python\n",
113
+ "client = PoelisClient.from_env()\n",
114
+ "```\n",
115
+ "\n",
116
+ "This method automatically reads the `POELIS_API_KEY` and `POELIS_ORG_ID` environment variables.\n"
117
+ ]
118
+ },
119
+ {
120
+ "cell_type": "code",
121
+ "execution_count": null,
122
+ "metadata": {},
123
+ "outputs": [],
124
+ "source": [
125
+ "# Example: Initialize client (replace with your actual credentials)\n",
126
+ "# For this demo, we'll use placeholder values\n",
127
+ "# In practice, use environment variables or replace with your actual credentials\n",
128
+ "\n",
129
+ "try:\n",
130
+ " # Try to initialize from environment variables first\n",
131
+ " client = PoelisClient.from_env()\n",
132
+ " print(\"✅ Client initialized from environment variables\")\n",
133
+ " print(f\"Base URL: {client.base_url}\")\n",
134
+ " print(f\"Organization ID: {client.org_id}\")\n",
135
+ "except ValueError as e:\n",
136
+ " print(f\"❌ Environment variables not set: {e}\")\n",
137
+ " print(\"Please set POELIS_API_KEY and POELIS_ORG_ID (POELIS_BASE_URL is optional)\")\n",
138
+ " \n",
139
+ "# Fallback to manual initialization (replace with your credentials)\n",
140
+ "client = PoelisClient(\n",
141
+ " api_key=\"your_api_key_here\",\n",
142
+ " org_id=\"your_org_id_here\"\n",
143
+ ")\n"
144
+ ]
145
+ },
146
+ {
147
+ "cell_type": "markdown",
148
+ "metadata": {},
149
+ "source": [
150
+ "## 4. Browser Interface - Interactive Exploration\n",
151
+ "\n",
152
+ "The Poelis SDK provides a powerful **browser interface** for interactive exploration of your data hierarchy. This is often the best way to start exploring your data!\n",
153
+ "\n",
154
+ "### What is the Browser Interface?\n",
155
+ "\n",
156
+ "The browser interface allows you to navigate through your data using **dot notation** and **tab completion**:\n",
157
+ "- `client.browser` - Start here\n",
158
+ "- `client.browser.<workspace>` - Access a specific workspace\n",
159
+ "- `client.browser.<workspace>.<product>` - Access a specific product\n",
160
+ "- `client.browser.<workspace>.<product>.<item>` - Access a specific item\n",
161
+ "- And so on...\n",
162
+ "\n"
163
+ ]
164
+ },
165
+ {
166
+ "cell_type": "code",
167
+ "execution_count": null,
168
+ "metadata": {},
169
+ "outputs": [],
170
+ "source": [
171
+ "from poelis_sdk import PoelisClient\n",
172
+ "\n",
173
+ "# 1) Create the client\n",
174
+ "client = PoelisClient(\n",
175
+ " api_key=\"YOUR_API_KEY\",\n",
176
+ " org_id=\"YOUR_ORG_ID\",\n",
177
+ ")\n",
178
+ "\n",
179
+ "# 2) Explore with TAB (completion is installed automatically when accessing client.browser):\n",
180
+ "# - Type: client.browser. and press TAB to see workspaces\n",
181
+ "# - Then: client.browser.<workspace>. and TAB to see products\n",
182
+ "# - Then: client.browser.<workspace>.<product>. and TAB to see items\n",
183
+ "# - On an item, TAB shows child items and property keys\n",
184
+ "\n",
185
+ "# 3) Access a property’s value and category via dot paths\n",
186
+ "# Example (replace names with your actual names shown by TAB):\n",
187
+ "workspace = client.browser.UH2_Workspace\n",
188
+ "val = workspace.LH2_Tank.cold_box.clv.mass.value\n",
189
+ "cat = workspace.LH2_Tank.cold_box.clv.mass.category\n",
190
+ "\n",
191
+ "print(\"property value:\", val)\n",
192
+ "print(\"property category:\", cat)\n",
193
+ "\n",
194
+ "# 4) Go deeper into child items if needed (TAB will suggest only item/property names)\n",
195
+ "inner = client.browser.UH2_Workspace.LH2_Tank.inner_tank"
196
+ ]
197
+ }
198
+ ],
199
+ "metadata": {
200
+ "kernelspec": {
201
+ "display_name": "Python 3",
202
+ "language": "python",
203
+ "name": "python3"
204
+ },
205
+ "language_info": {
206
+ "codemirror_mode": {
207
+ "name": "ipython",
208
+ "version": 3
209
+ },
210
+ "file_extension": ".py",
211
+ "mimetype": "text/x-python",
212
+ "name": "python",
213
+ "nbconvert_exporter": "python",
214
+ "pygments_lexer": "ipython3",
215
+ "version": "3.11.13"
216
+ }
217
+ },
218
+ "nbformat": 4,
219
+ "nbformat_minor": 2
220
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "poelis-sdk"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "Official Python SDK for Poelis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any, Dict, List, Optional
4
+ from types import MethodType
4
5
  import re
5
6
 
6
7
  from .org_validation import get_organization_context_message
@@ -12,6 +13,10 @@ with optional property listing on items. Designed for notebook UX.
12
13
  """
13
14
 
14
15
 
16
+ # Internal guard to avoid repeated completer installation
17
+ _AUTO_COMPLETER_INSTALLED: bool = False
18
+
19
+
15
20
  class _Node:
16
21
  def __init__(self, client: Any, level: str, parent: Optional["_Node"], node_id: Optional[str], name: Optional[str]) -> None:
17
22
  self._client = client
@@ -33,34 +38,37 @@ class _Node:
33
38
  def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
34
39
  # Ensure children are loaded so TAB shows options immediately
35
40
  self._load_children()
36
- keys = list(self._children_cache.keys()) + ["properties", "id", "name", "refresh", "names", "props"]
41
+ keys = list(self._children_cache.keys())
37
42
  if self._level == "item":
38
43
  # Include property names directly on item for suggestions
39
44
  prop_keys = list(self._props_key_map().keys())
40
45
  keys.extend(prop_keys)
41
- return sorted(keys)
46
+ return sorted(set(keys))
42
47
 
43
- @property
44
- def id(self) -> Optional[str]:
45
- return self._id
46
-
47
- @property
48
- def name(self) -> Optional[str]:
49
- return self._name
50
-
51
- def refresh(self) -> "_Node":
48
+ # Intentionally no public id/name/refresh to keep suggestions minimal
49
+ def _refresh(self) -> "_Node":
52
50
  self._children_cache.clear()
53
51
  self._props_cache = None
54
52
  return self
55
53
 
56
- def names(self) -> List[str]:
57
- """Return display names of children at this level (forces a lazy load)."""
54
+ def _names(self) -> List[str]:
55
+ """Return display names of children at this level (internal)."""
58
56
  self._load_children()
59
57
  return [child._name or "" for child in self._children_cache.values()]
60
58
 
59
+ def _suggest(self) -> List[str]:
60
+ """Return suggested attribute names for interactive usage.
61
+
62
+ Only child keys are returned; for item level, property keys are also included.
63
+ """
64
+ self._load_children()
65
+ suggestions: List[str] = list(self._children_cache.keys())
66
+ if self._level == "item":
67
+ suggestions.extend(list(self._props_key_map().keys()))
68
+ return sorted(set(suggestions))
69
+
61
70
  def __getattr__(self, attr: str) -> Any:
62
- if attr in {"properties", "id", "name", "refresh"}:
63
- return object.__getattribute__(self, attr)
71
+ # No public properties/id/name/refresh
64
72
  if attr == "props": # item-level properties pseudo-node
65
73
  if self._level != "item":
66
74
  raise AttributeError("props")
@@ -92,18 +100,17 @@ class _Node:
92
100
  return self._children_cache[safe]
93
101
  raise KeyError(key)
94
102
 
95
- @property
96
- def properties(self) -> List[Dict[str, Any]]:
103
+ def _properties(self) -> List[Dict[str, Any]]:
97
104
  if self._props_cache is not None:
98
105
  return self._props_cache
99
106
  if self._level != "item":
100
107
  self._props_cache = []
101
108
  return self._props_cache
102
- # Try direct properties(item_id: ...) first; fallback to searchProperties
109
+ # Try direct properties(itemId: ...) first; fallback to searchProperties
103
110
  q = (
104
111
  "query($iid: ID!) {\n"
105
- " properties(item_id: $iid) {\n"
106
- " __typename id name owner\n"
112
+ " properties(itemId: $iid) {\n"
113
+ " __typename\n"
107
114
  " ... on NumericProperty { integerPart exponent category }\n"
108
115
  " ... on TextProperty { value }\n"
109
116
  " ... on DateProperty { value }\n"
@@ -121,7 +128,7 @@ class _Node:
121
128
  q2 = (
122
129
  "query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
123
130
  " searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
124
- " hits { id name propertyType category textValue numericValue dateValue owner }\n"
131
+ " hits { id workspaceId productId itemId propertyType name category value owner }\n"
125
132
  " }\n"
126
133
  "}"
127
134
  )
@@ -138,10 +145,20 @@ class _Node:
138
145
  out: Dict[str, Dict[str, Any]] = {}
139
146
  if self._level != "item":
140
147
  return out
141
- props = self.properties
142
- for pr in props:
143
- display = pr.get("name") or pr.get("id")
148
+ props = self._properties()
149
+ used_names: Dict[str, int] = {}
150
+ for i, pr in enumerate(props):
151
+ # Try to get name from various possible fields
152
+ display = pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
144
153
  safe = _safe_key(str(display))
154
+
155
+ # Handle duplicate names by adding a suffix
156
+ if safe in used_names:
157
+ used_names[safe] += 1
158
+ safe = f"{safe}_{used_names[safe]}"
159
+ else:
160
+ used_names[safe] = 0
161
+
145
162
  out[safe] = _PropWrapper(pr)
146
163
  return out
147
164
 
@@ -178,7 +195,7 @@ class _Node:
178
195
  return
179
196
  q = (
180
197
  "query($pid: ID!, $parent: ID!, $limit: Int!, $offset: Int!) {\n"
181
- " items(productId: $pid, parentItemId: $parent, limit: $limit, offset: $offset) { id name code description productId parentId owner }\n"
198
+ " items(productId: $pid, parentItemId: $parent, limit: $limit, offset: $offset) { id name code description productId parentId owner position }\n"
182
199
  "}"
183
200
  )
184
201
  r = self._client._transport.graphql(q, {"pid": pid, "parent": self._id, "limit": 1000, "offset": 0})
@@ -201,6 +218,15 @@ class Browser:
201
218
 
202
219
  def __init__(self, client: Any) -> None:
203
220
  self._root = _Node(client, "root", None, None, None)
221
+ # Best-effort: auto-enable curated completion in interactive shells
222
+ global _AUTO_COMPLETER_INSTALLED
223
+ if not _AUTO_COMPLETER_INSTALLED:
224
+ try:
225
+ if enable_dynamic_completion():
226
+ _AUTO_COMPLETER_INSTALLED = True
227
+ except Exception:
228
+ # Non-interactive or IPython not available; ignore silently
229
+ pass
204
230
 
205
231
  def __getattr__(self, attr: str) -> Any: # pragma: no cover - notebook UX
206
232
  return getattr(self._root, attr)
@@ -217,7 +243,15 @@ class Browser:
217
243
  def __dir__(self) -> list[str]: # pragma: no cover - notebook UX
218
244
  # Ensure children are loaded so TAB shows options
219
245
  self._root._load_children()
220
- return sorted([*self._root._children_cache.keys(), "names"])
246
+ return sorted([*self._root._children_cache.keys()])
247
+
248
+ def _names(self) -> List[str]:
249
+ """Return display names of root-level children (workspaces)."""
250
+ return self._root._names()
251
+
252
+ # keep suggest internal so it doesn't appear in help/dir
253
+ def _suggest(self) -> List[str]:
254
+ return self._root._suggest()
221
255
 
222
256
 
223
257
  def _safe_key(name: str) -> str:
@@ -245,16 +279,28 @@ class _PropsNode:
245
279
  def _ensure_loaded(self) -> None:
246
280
  if self._children_cache:
247
281
  return
248
- props = self._item.properties
249
- for pr in props:
250
- display = pr.get("name") or pr.get("id")
282
+ props = self._item._properties()
283
+ used_names: Dict[str, int] = {}
284
+ names_list = []
285
+ for i, pr in enumerate(props):
286
+ # Try to get name from various possible fields
287
+ display = pr.get("name") or pr.get("id") or pr.get("category") or f"property_{i}"
251
288
  safe = _safe_key(str(display))
289
+
290
+ # Handle duplicate names by adding a suffix
291
+ if safe in used_names:
292
+ used_names[safe] += 1
293
+ safe = f"{safe}_{used_names[safe]}"
294
+ else:
295
+ used_names[safe] = 0
296
+
252
297
  self._children_cache[safe] = _PropWrapper(pr)
253
- self._names = [p.get("name") or p.get("id") for p in props]
298
+ names_list.append(display)
299
+ self._names = names_list
254
300
 
255
301
  def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
256
302
  self._ensure_loaded()
257
- return sorted(list(self._children_cache.keys()) + ["names"])
303
+ return sorted(list(self._children_cache.keys()))
258
304
 
259
305
  def names(self) -> List[str]:
260
306
  self._ensure_loaded()
@@ -272,13 +318,21 @@ class _PropsNode:
272
318
  return self._children_cache[key]
273
319
  # match by display name
274
320
  for safe, data in self._children_cache.items():
275
- if data.raw.get("name") == key:
276
- return data
321
+ try:
322
+ if getattr(data, "_raw", {}).get("name") == key: # type: ignore[arg-type]
323
+ return data
324
+ except Exception:
325
+ continue
277
326
  safe = _safe_key(key)
278
327
  if safe in self._children_cache:
279
328
  return self._children_cache[safe]
280
329
  raise KeyError(key)
281
330
 
331
+ # keep suggest internal so it doesn't appear in help/dir
332
+ def _suggest(self) -> List[str]:
333
+ self._ensure_loaded()
334
+ return sorted(list(self._children_cache.keys()))
335
+
282
336
 
283
337
  class _PropWrapper:
284
338
  """Lightweight accessor for a property dict, exposing `.value` and `.raw`.
@@ -289,10 +343,6 @@ class _PropWrapper:
289
343
  def __init__(self, prop: Dict[str, Any]) -> None:
290
344
  self._raw = prop
291
345
 
292
- @property
293
- def raw(self) -> Dict[str, Any]:
294
- return self._raw
295
-
296
346
  @property
297
347
  def value(self) -> Any: # type: ignore[override]
298
348
  p = self._raw
@@ -315,8 +365,123 @@ class _PropWrapper:
315
365
  return p.get("value")
316
366
  return None
317
367
 
368
+ @property
369
+ def category(self) -> Optional[str]:
370
+ p = self._raw
371
+ cat = p.get("category")
372
+ return str(cat) if cat is not None else None
373
+
374
+ def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
375
+ # Expose only the minimal attributes for browsing
376
+ return ["value", "category"]
377
+
318
378
  def __repr__(self) -> str: # pragma: no cover - notebook UX
319
379
  name = self._raw.get("name") or self._raw.get("id")
320
380
  return f"<property {name}: {self.value}>"
321
381
 
322
382
 
383
+
384
+ def enable_dynamic_completion() -> bool:
385
+ """Enable dynamic attribute completion in IPython/Jupyter environments.
386
+
387
+ This helper attempts to configure IPython to use runtime-based completion
388
+ (disabling Jedi) so that our dynamic `__dir__` and `suggest()` methods are
389
+ respected by TAB completion. Returns True if an interactive shell was found
390
+ and configured, False otherwise.
391
+ """
392
+
393
+ try:
394
+ # Deferred import to avoid hard dependency
395
+ from IPython import get_ipython # type: ignore
396
+ except Exception:
397
+ return False
398
+
399
+ ip = None
400
+ try:
401
+ ip = get_ipython() # type: ignore[assignment]
402
+ except Exception:
403
+ ip = None
404
+ if ip is None:
405
+ return False
406
+
407
+ enabled = False
408
+ # Best-effort configuration: rely on IPython's fallback (non-Jedi) completer
409
+ try:
410
+ if hasattr(ip, "Completer") and hasattr(ip.Completer, "use_jedi"):
411
+ # Disable Jedi to let IPython consult __dir__ dynamically
412
+ ip.Completer.use_jedi = False # type: ignore[assignment]
413
+ # Greedy completion improves attribute completion depth
414
+ if hasattr(ip.Completer, "greedy"):
415
+ ip.Completer.greedy = True # type: ignore[assignment]
416
+ enabled = True
417
+ except Exception:
418
+ pass
419
+
420
+ # Additionally, install a lightweight attribute completer that uses suggest()
421
+ try:
422
+ comp = getattr(ip, "Completer", None)
423
+ if comp is not None and hasattr(comp, "attr_matches"):
424
+ orig_attr_matches = comp.attr_matches # type: ignore[attr-defined]
425
+
426
+ def _poelis_attr_matches(self: Any, text: str) -> List[str]: # pragma: no cover - interactive behavior
427
+ try:
428
+ # text is like "client.browser.uh2.pr" → split at last dot
429
+ obj_expr, _, prefix = text.rpartition(".")
430
+ if not obj_expr:
431
+ return orig_attr_matches(text) # type: ignore[operator]
432
+ # Evaluate the object in the user namespace
433
+ ns = getattr(self, "namespace", {})
434
+ obj_val = eval(obj_expr, ns, ns)
435
+
436
+ # For Poelis browser objects, show ONLY our curated suggestions
437
+ from_types = (Browser, _Node, _PropsNode, _PropWrapper)
438
+ if isinstance(obj_val, from_types):
439
+ # Build suggestion list
440
+ if isinstance(obj_val, _PropWrapper):
441
+ sugg: List[str] = ["value", "category"]
442
+ elif hasattr(obj_val, "_suggest"):
443
+ sugg = list(getattr(obj_val, "_suggest")()) # type: ignore[no-untyped-call]
444
+ else:
445
+ sugg = list(dir(obj_val))
446
+ # Filter by prefix and format matches as full attribute paths
447
+ out: List[str] = []
448
+ for s in sugg:
449
+ if not prefix or str(s).startswith(prefix):
450
+ out.append(f"{obj_expr}.{s}")
451
+ return out
452
+
453
+ # Otherwise, fall back to default behavior
454
+ return orig_attr_matches(text) # type: ignore[operator]
455
+ except Exception:
456
+ # fall back to original on any error
457
+ return orig_attr_matches(text) # type: ignore[operator]
458
+
459
+ comp.attr_matches = MethodType(_poelis_attr_matches, comp) # type: ignore[assignment]
460
+ enabled = True
461
+ except Exception:
462
+ pass
463
+
464
+ # Also register as a high-priority matcher in IPCompleter.matchers
465
+ try:
466
+ comp = getattr(ip, "Completer", None)
467
+ if comp is not None and hasattr(comp, "matchers") and not getattr(comp, "_poelis_matcher_installed", False):
468
+ orig_attr_matches = comp.attr_matches # type: ignore[attr-defined]
469
+
470
+ def _poelis_matcher(self: Any, text: str) -> List[str]: # pragma: no cover - interactive behavior
471
+ # Delegate to our attribute logic for dotted expressions; otherwise empty
472
+ if "." in text:
473
+ try:
474
+ return self.attr_matches(text) # type: ignore[operator]
475
+ except Exception:
476
+ return orig_attr_matches(text) # type: ignore[operator]
477
+ return []
478
+
479
+ # Prepend our matcher so it's consulted early
480
+ comp.matchers.insert(0, MethodType(_poelis_matcher, comp)) # type: ignore[arg-type]
481
+ setattr(comp, "_poelis_matcher_installed", True)
482
+ enabled = True
483
+ except Exception:
484
+ pass
485
+
486
+ return bool(enabled)
487
+