poelis-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -0,0 +1,34 @@
1
+ name: SDK CI
2
+
3
+ on:
4
+ push:
5
+ paths:
6
+ - 'sdk/python/**'
7
+ pull_request:
8
+ paths:
9
+ - 'sdk/python/**'
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ defaults:
15
+ run:
16
+ working-directory: sdk/python
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: '3.11'
23
+ - name: Install uv
24
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
25
+ - name: Install deps
26
+ run: |
27
+ ~/.local/bin/uv pip install -e .
28
+ ~/.local/bin/uv pip install pytest ruff
29
+ - name: Lint (ruff)
30
+ run: ~/.local/bin/uv run ruff check src tests
31
+ - name: Test (pytest)
32
+ run: ~/.local/bin/uv run pytest -q
33
+
34
+
@@ -0,0 +1,55 @@
1
+ name: SDK Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ paths:
7
+ - 'sdk/python/docs/**'
8
+ - 'sdk/python/mkdocs.yml'
9
+ - 'sdk/python/src/**'
10
+
11
+ permissions:
12
+ contents: read
13
+ pages: write
14
+ id-token: write
15
+
16
+ concurrency:
17
+ group: 'pages'
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ build:
22
+ runs-on: ubuntu-latest
23
+ defaults:
24
+ run:
25
+ working-directory: sdk/python
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: '3.11'
31
+ - name: Install uv
32
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
33
+ - name: Install docs deps
34
+ run: |
35
+ ~/.local/bin/uv pip install -e .[docs]
36
+ - name: Build docs
37
+ run: |
38
+ ~/.local/bin/uv run mkdocs build --strict
39
+ - name: Upload artifact
40
+ uses: actions/upload-pages-artifact@v3
41
+ with:
42
+ path: sdk/python/site
43
+
44
+ deploy:
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ environment:
48
+ name: github-pages
49
+ url: ${{ steps.deployment.outputs.page_url }}
50
+ steps:
51
+ - name: Deploy to GitHub Pages
52
+ id: deployment
53
+ uses: actions/deploy-pages@v4
54
+
55
+
@@ -0,0 +1,31 @@
1
+ name: SDK Publish (TestPyPI)
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'sdk-v*'
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+ defaults:
12
+ run:
13
+ working-directory: sdk/python
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.11'
19
+ - name: Install uv
20
+ run: curl -LsSf https://astral.sh/uv/install.sh | sh
21
+ - name: Build wheel and sdist
22
+ run: ~/.local/bin/uv build
23
+ - name: Publish to TestPyPI
24
+ env:
25
+ TWINE_USERNAME: __token__
26
+ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}
27
+ run: |
28
+ ~/.local/bin/uv pip install twine
29
+ ~/.local/bin/uv run twine upload --repository-url https://test.pypi.org/legacy/ dist/*
30
+
31
+
poelis_sdk/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Poelis Python SDK public exports."""
2
+
3
+ from .client import PoelisClient
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["PoelisClient"]
7
+
8
+
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ """HTTP transport abstraction for the Poelis SDK.
4
+
5
+ Provides a thin wrapper around httpx with sensible defaults for timeouts,
6
+ retries, and headers including authentication and optional org scoping.
7
+ """
8
+
9
+ from typing import Any, Dict, Mapping, Optional
10
+
11
+ import time
12
+ import random
13
+ import httpx
14
+
15
+ from .exceptions import ClientError, HTTPError, NotFoundError, RateLimitError, ServerError, UnauthorizedError
16
+
17
+
18
+ class Transport:
19
+ """Synchronous HTTP transport using httpx.Client.
20
+
21
+ This wrapper centralizes auth headers, tenant scoping, timeouts, and
22
+ retry behavior. Retries are implemented here in a simple, explicit way to
23
+ avoid external dependencies, following the professional defaults defined
24
+ in the SDK planning document.
25
+ """
26
+
27
+ def __init__(self, base_url: str, api_key: str, org_id: str, timeout_seconds: float) -> None:
28
+ """Initialize the transport.
29
+
30
+ Args:
31
+ base_url: Base API URL.
32
+ token: Bearer token for Authorization header.
33
+ org_id: Optional organization id for tenant scoping.
34
+ timeout_seconds: Request timeout in seconds.
35
+ """
36
+
37
+ self._client = httpx.Client(base_url=base_url, timeout=timeout_seconds)
38
+ self._api_key = api_key
39
+ self._org_id = org_id
40
+ self._timeout = timeout_seconds
41
+
42
+ def _headers(self, extra: Optional[Mapping[str, str]] = None) -> Dict[str, str]:
43
+ headers: Dict[str, str] = {
44
+ "Accept": "application/json",
45
+ "Content-Type": "application/json",
46
+ }
47
+ headers["Authorization"] = f"ApiKey {self._api_key}"
48
+ headers["X-Poelis-Org"] = self._org_id
49
+ if extra:
50
+ headers.update(dict(extra))
51
+ return headers
52
+
53
+ def get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> httpx.Response:
54
+ return self._request("GET", path, params=params)
55
+
56
+ def post(self, path: str, json: Any = None) -> httpx.Response: # noqa: A003
57
+ return self._request("POST", path, json=json)
58
+
59
+ def patch(self, path: str, json: Any = None) -> httpx.Response:
60
+ return self._request("PATCH", path, json=json)
61
+
62
+ def delete(self, path: str) -> httpx.Response:
63
+ return self._request("DELETE", path)
64
+
65
+ def graphql(self, query: str, variables: Optional[Mapping[str, Any]] = None) -> httpx.Response:
66
+ """Post a GraphQL operation to /v1/graphql.
67
+
68
+ Args:
69
+ query: GraphQL document string.
70
+ variables: Optional mapping of variables.
71
+ """
72
+
73
+ payload: Dict[str, Any] = {"query": query, "variables": dict(variables or {})}
74
+ return self._request("POST", "/v1/graphql", json=payload)
75
+
76
+ def _request(self, method: str, path: str, *, params: Optional[Mapping[str, Any]] = None, json: Any = None) -> httpx.Response:
77
+ # Retries: up to 3 attempts on idempotent GET/HEAD and 429s respecting Retry-After.
78
+ max_attempts = 3
79
+ last_exc: Optional[Exception] = None
80
+ for attempt in range(1, max_attempts + 1):
81
+ try:
82
+ response = self._client.request(method, path, headers=self._headers(), params=params, json=json)
83
+ # Map common error codes
84
+ if 200 <= response.status_code < 300:
85
+ return response
86
+ if response.status_code == 401:
87
+ raise UnauthorizedError(401, message=_safe_message(response))
88
+ if response.status_code == 404:
89
+ raise NotFoundError(404, message=_safe_message(response))
90
+ if response.status_code == 429:
91
+ retry_after_header = response.headers.get("Retry-After")
92
+ retry_after: Optional[float] = None
93
+ if retry_after_header:
94
+ try:
95
+ retry_after = float(retry_after_header)
96
+ except Exception:
97
+ retry_after = None
98
+ if attempt < max_attempts:
99
+ if retry_after is not None:
100
+ time.sleep(retry_after)
101
+ else:
102
+ # fallback exponential backoff with jitter
103
+ time.sleep(_backoff_sleep(attempt))
104
+ continue
105
+ raise RateLimitError(429, message=_safe_message(response), retry_after_seconds=retry_after)
106
+ if 400 <= response.status_code < 500:
107
+ raise ClientError(response.status_code, message=_safe_message(response))
108
+ if 500 <= response.status_code < 600:
109
+ # Retry on idempotent
110
+ if method in {"GET", "HEAD"} and attempt < max_attempts:
111
+ time.sleep(_backoff_sleep(attempt))
112
+ continue
113
+ raise ServerError(response.status_code, message=_safe_message(response))
114
+ # Fallback
115
+ raise HTTPError(response.status_code, message=_safe_message(response))
116
+ except httpx.HTTPError as exc:
117
+ last_exc = exc
118
+ if method not in {"GET", "HEAD"} or attempt == max_attempts:
119
+ raise
120
+ assert last_exc is not None
121
+ raise last_exc
122
+
123
+
124
+ def _safe_message(response: httpx.Response) -> str:
125
+ try:
126
+ data = response.json()
127
+ if isinstance(data, dict):
128
+ msg = data.get("message") or data.get("detail") or data.get("error")
129
+ if isinstance(msg, str):
130
+ return msg
131
+ return response.text
132
+ except Exception:
133
+ return response.text
134
+
135
+
136
+ def _backoff_sleep(attempt: int) -> float:
137
+ # Exponential backoff with jitter: base 0.5s, cap ~4s
138
+ base = 0.5 * (2 ** (attempt - 1))
139
+ return min(4.0, base + random.uniform(0, 0.25))
140
+
141
+
poelis_sdk/browser.py ADDED
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ """GraphQL-backed dot-path browser for Poelis SDK.
4
+
5
+ Provides lazy, name-based navigation across workspaces → products → items → child items,
6
+ with optional property listing on items. Designed for notebook UX.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional
10
+ import re
11
+
12
+
13
+ class _Node:
14
+ def __init__(self, client: Any, level: str, parent: Optional["_Node"], node_id: Optional[str], name: Optional[str]) -> None:
15
+ self._client = client
16
+ self._level = level
17
+ self._parent = parent
18
+ self._id = node_id
19
+ self._name = name
20
+ self._children_cache: Dict[str, "_Node"] = {}
21
+ self._props_cache: Optional[List[Dict[str, Any]]] = None
22
+
23
+ def __repr__(self) -> str: # pragma: no cover - notebook UX
24
+ path = []
25
+ cur: Optional[_Node] = self
26
+ while cur is not None and cur._name:
27
+ path.append(cur._name)
28
+ cur = cur._parent
29
+ return f"<{self._level}:{'.'.join(reversed(path)) or '*'}>"
30
+
31
+ def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
32
+ # Ensure children are loaded so TAB shows options immediately
33
+ self._load_children()
34
+ keys = list(self._children_cache.keys()) + ["properties", "id", "name", "refresh", "names", "props"]
35
+ if self._level == "item":
36
+ # Include property names directly on item for suggestions
37
+ prop_keys = list(self._props_key_map().keys())
38
+ keys.extend(prop_keys)
39
+ return sorted(keys)
40
+
41
+ @property
42
+ def id(self) -> Optional[str]:
43
+ return self._id
44
+
45
+ @property
46
+ def name(self) -> Optional[str]:
47
+ return self._name
48
+
49
+ def refresh(self) -> "_Node":
50
+ self._children_cache.clear()
51
+ self._props_cache = None
52
+ return self
53
+
54
+ def names(self) -> List[str]:
55
+ """Return display names of children at this level (forces a lazy load)."""
56
+ self._load_children()
57
+ return [child._name or "" for child in self._children_cache.values()]
58
+
59
+ def __getattr__(self, attr: str) -> Any:
60
+ if attr in {"properties", "id", "name", "refresh"}:
61
+ return object.__getattribute__(self, attr)
62
+ if attr == "props": # item-level properties pseudo-node
63
+ if self._level != "item":
64
+ raise AttributeError("props")
65
+ return _PropsNode(self)
66
+ if attr not in self._children_cache:
67
+ self._load_children()
68
+ if attr in self._children_cache:
69
+ return self._children_cache[attr]
70
+ # Expose properties as direct attributes on item level
71
+ if self._level == "item":
72
+ pk = self._props_key_map()
73
+ if attr in pk:
74
+ return pk[attr]
75
+ raise AttributeError(attr)
76
+
77
+ def __getitem__(self, key: str) -> "_Node":
78
+ """Access child by display name or a safe attribute key.
79
+
80
+ This enables names with spaces or symbols: browser["Workspace Name"].
81
+ """
82
+ self._load_children()
83
+ if key in self._children_cache:
84
+ return self._children_cache[key]
85
+ for child in self._children_cache.values():
86
+ if child._name == key:
87
+ return child
88
+ safe = _safe_key(key)
89
+ if safe in self._children_cache:
90
+ return self._children_cache[safe]
91
+ raise KeyError(key)
92
+
93
+ @property
94
+ def properties(self) -> List[Dict[str, Any]]:
95
+ if self._props_cache is not None:
96
+ return self._props_cache
97
+ if self._level != "item":
98
+ self._props_cache = []
99
+ return self._props_cache
100
+ # Try direct properties(item_id: ...) first; fallback to searchProperties
101
+ q = (
102
+ "query($iid: ID!) {\n"
103
+ " properties(item_id: $iid) {\n"
104
+ " __typename id name owner\n"
105
+ " ... on NumericProperty { integerPart exponent category }\n"
106
+ " ... on TextProperty { value }\n"
107
+ " ... on DateProperty { value }\n"
108
+ " }\n"
109
+ "}"
110
+ )
111
+ try:
112
+ r = self._client._transport.graphql(q, {"iid": self._id})
113
+ r.raise_for_status()
114
+ data = r.json()
115
+ if "errors" in data:
116
+ raise RuntimeError(data["errors"]) # trigger fallback
117
+ self._props_cache = data.get("data", {}).get("properties", []) or []
118
+ except Exception:
119
+ q2 = (
120
+ "query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
121
+ " searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
122
+ " hits { id name propertyType category textValue numericValue dateValue owner }\n"
123
+ " }\n"
124
+ "}"
125
+ )
126
+ r2 = self._client._transport.graphql(q2, {"iid": self._id, "limit": 100, "offset": 0})
127
+ r2.raise_for_status()
128
+ data2 = r2.json()
129
+ if "errors" in data2:
130
+ raise RuntimeError(data2["errors"]) # propagate
131
+ self._props_cache = data2.get("data", {}).get("searchProperties", {}).get("hits", []) or []
132
+ return self._props_cache
133
+
134
+ def _props_key_map(self) -> Dict[str, Dict[str, Any]]:
135
+ """Map safe keys to property wrappers for item-level attribute access."""
136
+ out: Dict[str, Dict[str, Any]] = {}
137
+ if self._level != "item":
138
+ return out
139
+ props = self.properties
140
+ for pr in props:
141
+ display = pr.get("name") or pr.get("id")
142
+ safe = _safe_key(str(display))
143
+ out[safe] = _PropWrapper(pr)
144
+ return out
145
+
146
+ def _load_children(self) -> None:
147
+ if self._level == "root":
148
+ rows = self._client.workspaces.list(limit=200, offset=0)
149
+ for w in rows:
150
+ display = w.get("name") or str(w.get("id"))
151
+ nm = _safe_key(display)
152
+ self._children_cache[nm] = _Node(self._client, "workspace", self, w["id"], display)
153
+ elif self._level == "workspace":
154
+ page = self._client.products.list_by_workspace(workspace_id=self._id, limit=200, offset=0)
155
+ for p in page.data:
156
+ display = p.name or str(p.id)
157
+ nm = _safe_key(display)
158
+ self._children_cache[nm] = _Node(self._client, "product", self, p.id, display)
159
+ elif self._level == "product":
160
+ rows = self._client.items.list_by_product(product_id=self._id, limit=1000, offset=0)
161
+ for it in rows:
162
+ if it.get("parentId") is None:
163
+ display = it.get("name") or str(it["id"])
164
+ nm = _safe_key(display)
165
+ self._children_cache[nm] = _Node(self._client, "item", self, it["id"], display)
166
+ elif self._level == "item":
167
+ # Fetch children items by parent; derive productId from ancestor product
168
+ anc = self
169
+ pid: Optional[str] = None
170
+ while anc is not None:
171
+ if anc._level == "product":
172
+ pid = anc._id
173
+ break
174
+ anc = anc._parent # type: ignore[assignment]
175
+ if not pid:
176
+ return
177
+ q = (
178
+ "query($pid: ID!, $parent: ID!, $limit: Int!, $offset: Int!) {\n"
179
+ " items(productId: $pid, parentItemId: $parent, limit: $limit, offset: $offset) { id name code description productId parentId owner }\n"
180
+ "}"
181
+ )
182
+ r = self._client._transport.graphql(q, {"pid": pid, "parent": self._id, "limit": 1000, "offset": 0})
183
+ r.raise_for_status()
184
+ data = r.json()
185
+ if "errors" in data:
186
+ raise RuntimeError(data["errors"]) # surface
187
+ rows = data.get("data", {}).get("items", []) or []
188
+ for it2 in rows:
189
+ # Skip the current item (GraphQL returns parent + direct children)
190
+ if str(it2.get("id")) == str(self._id):
191
+ continue
192
+ display = it2.get("name") or str(it2["id"])
193
+ nm = _safe_key(display)
194
+ self._children_cache[nm] = _Node(self._client, "item", self, it2["id"], display)
195
+
196
+
197
+ class Browser:
198
+ """Public browser entrypoint."""
199
+
200
+ def __init__(self, client: Any) -> None:
201
+ self._root = _Node(client, "root", None, None, None)
202
+
203
+ def __getattr__(self, attr: str) -> Any: # pragma: no cover - notebook UX
204
+ return getattr(self._root, attr)
205
+
206
+ def __repr__(self) -> str: # pragma: no cover - notebook UX
207
+ return "<browser root>"
208
+
209
+ def __getitem__(self, key: str) -> Any: # pragma: no cover - notebook UX
210
+ """Delegate index-based access to the root node so names work: browser["Workspace Name"]."""
211
+ return self._root[key]
212
+
213
+ def __dir__(self) -> list[str]: # pragma: no cover - notebook UX
214
+ # Ensure children are loaded so TAB shows options
215
+ self._root._load_children()
216
+ return sorted([*self._root._children_cache.keys(), "names"])
217
+
218
+
219
+ def _safe_key(name: str) -> str:
220
+ """Convert arbitrary display name to a safe attribute key (letters/digits/_)."""
221
+ key = re.sub(r"[^0-9a-zA-Z_]+", "_", name)
222
+ key = key.strip("_")
223
+ return key or "_"
224
+
225
+
226
+ class _PropsNode:
227
+ """Pseudo-node that exposes item properties as child attributes by display name.
228
+
229
+ Usage: item.props.<Property_Name> or item.props["Property Name"].
230
+ Returns the raw property dictionaries from GraphQL.
231
+ """
232
+
233
+ def __init__(self, item_node: _Node) -> None:
234
+ self._item = item_node
235
+ self._children_cache: Dict[str, _PropWrapper] = {}
236
+ self._names: List[str] = []
237
+
238
+ def __repr__(self) -> str: # pragma: no cover - notebook UX
239
+ return f"<props of {self._item.name or self._item.id}>"
240
+
241
+ def _ensure_loaded(self) -> None:
242
+ if self._children_cache:
243
+ return
244
+ props = self._item.properties
245
+ for pr in props:
246
+ display = pr.get("name") or pr.get("id")
247
+ safe = _safe_key(str(display))
248
+ self._children_cache[safe] = _PropWrapper(pr)
249
+ self._names = [p.get("name") or p.get("id") for p in props]
250
+
251
+ def __dir__(self) -> List[str]: # pragma: no cover - notebook UX
252
+ self._ensure_loaded()
253
+ return sorted(list(self._children_cache.keys()) + ["names"])
254
+
255
+ def names(self) -> List[str]:
256
+ self._ensure_loaded()
257
+ return list(self._names)
258
+
259
+ def __getattr__(self, attr: str) -> Any:
260
+ self._ensure_loaded()
261
+ if attr in self._children_cache:
262
+ return self._children_cache[attr]
263
+ raise AttributeError(attr)
264
+
265
+ def __getitem__(self, key: str) -> Any:
266
+ self._ensure_loaded()
267
+ if key in self._children_cache:
268
+ return self._children_cache[key]
269
+ # match by display name
270
+ for safe, data in self._children_cache.items():
271
+ if data.raw.get("name") == key:
272
+ return data
273
+ safe = _safe_key(key)
274
+ if safe in self._children_cache:
275
+ return self._children_cache[safe]
276
+ raise KeyError(key)
277
+
278
+
279
+ class _PropWrapper:
280
+ """Lightweight accessor for a property dict, exposing `.value` and `.raw`.
281
+
282
+ Normalizes different property result shapes (union vs search) into `.value`.
283
+ """
284
+
285
+ def __init__(self, prop: Dict[str, Any]) -> None:
286
+ self._raw = prop
287
+
288
+ @property
289
+ def raw(self) -> Dict[str, Any]:
290
+ return self._raw
291
+
292
+ @property
293
+ def value(self) -> Any: # type: ignore[override]
294
+ p = self._raw
295
+ # searchProperties shape
296
+ if "numericValue" in p and p.get("numericValue") is not None:
297
+ return p["numericValue"]
298
+ if "textValue" in p and p.get("textValue") is not None:
299
+ return p["textValue"]
300
+ if "dateValue" in p and p.get("dateValue") is not None:
301
+ return p["dateValue"]
302
+ # union shape
303
+ if "integerPart" in p:
304
+ integer_part = p.get("integerPart")
305
+ exponent = p.get("exponent", 0) or 0
306
+ try:
307
+ return (integer_part or 0) * (10 ** int(exponent))
308
+ except Exception:
309
+ return integer_part
310
+ if "value" in p:
311
+ return p.get("value")
312
+ return None
313
+
314
+ def __repr__(self) -> str: # pragma: no cover - notebook UX
315
+ name = self._raw.get("name") or self._raw.get("id")
316
+ return f"<property {name}: {self.value}>"
317
+
318
+
poelis_sdk/client.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ """Core client for the Poelis Python SDK.
4
+
5
+ This module exposes the `PoelisClient` which configures base URL, authentication,
6
+ tenant scoping, and provides accessors for resource clients. The initial
7
+ implementation is sync-first and keeps the transport layer swappable for
8
+ future async parity.
9
+ """
10
+
11
+ from typing import Optional
12
+
13
+ from pydantic import BaseModel, Field, HttpUrl
14
+ from ._transport import Transport
15
+ from .products import ProductsClient
16
+ from .items import ItemsClient
17
+ from .search import SearchClient
18
+ from .workspaces import WorkspacesClient
19
+ from .browser import Browser
20
+ import os
21
+
22
+
23
+ class ClientConfig(BaseModel):
24
+ """Configuration for `PoelisClient`.
25
+
26
+ Attributes:
27
+ base_url: Base URL of the Poelis API.
28
+ api_key: API key used for authentication.
29
+ org_id: Organization id for multi-tenancy scoping.
30
+ timeout_seconds: Request timeout in seconds.
31
+ """
32
+
33
+ base_url: HttpUrl
34
+ api_key: str = Field(min_length=1)
35
+ org_id: str = Field(min_length=1)
36
+ timeout_seconds: float = 30.0
37
+
38
+
39
+ class PoelisClient:
40
+ """Synchronous Poelis SDK client.
41
+
42
+ Provides access to resource-specific clients (e.g., `products`, `items`).
43
+ This prototype only validates configuration and exposes placeholders for
44
+ resource accessors to unblock incremental development.
45
+ """
46
+
47
+ def __init__(self, base_url: str, api_key: str, org_id: str, timeout_seconds: float = 30.0) -> None:
48
+ """Initialize the client with API endpoint and credentials.
49
+
50
+ Args:
51
+ base_url: Base URL of the Poelis API.
52
+ api_key: API key for API authentication.
53
+ org_id: Tenant organization id to scope requests.
54
+ timeout_seconds: Network timeout in seconds.
55
+ """
56
+
57
+ self._config = ClientConfig(
58
+ base_url=base_url,
59
+ api_key=api_key,
60
+ org_id=org_id,
61
+ timeout_seconds=timeout_seconds,
62
+ )
63
+
64
+ # Shared transport
65
+ self._transport = Transport(
66
+ base_url=str(self._config.base_url),
67
+ api_key=self._config.api_key,
68
+ org_id=self._config.org_id,
69
+ timeout_seconds=self._config.timeout_seconds,
70
+ )
71
+
72
+ # Resource clients
73
+ self.products = ProductsClient(self._transport)
74
+ self.items = ItemsClient(self._transport)
75
+ self.search = SearchClient(self._transport)
76
+ self.workspaces = WorkspacesClient(self._transport)
77
+ self.browser = Browser(self)
78
+
79
+ @classmethod
80
+ def from_env(cls) -> "PoelisClient":
81
+ """Construct a client using environment variables.
82
+
83
+ Expected variables:
84
+ - POELIS_BASE_URL
85
+ - POELIS_API_KEY
86
+ - POELIS_ORG_ID
87
+ """
88
+
89
+ base_url = os.environ.get("POELIS_BASE_URL")
90
+ api_key = os.environ.get("POELIS_API_KEY")
91
+ org_id = os.environ.get("POELIS_ORG_ID")
92
+
93
+ if not base_url:
94
+ raise ValueError("POELIS_BASE_URL must be set")
95
+ if not api_key:
96
+ raise ValueError("POELIS_API_KEY must be set")
97
+ if not org_id:
98
+ raise ValueError("POELIS_ORG_ID must be set")
99
+
100
+ return cls(base_url=base_url, api_key=api_key, org_id=org_id)
101
+
102
+ @property
103
+ def base_url(self) -> str:
104
+ """Return the configured base URL as a string."""
105
+
106
+ return str(self._config.base_url)
107
+
108
+ @property
109
+ def org_id(self) -> Optional[str]:
110
+ """Return the configured organization id if any."""
111
+
112
+ return self._config.org_id
113
+
114
+
115
+ class _Deprecated: # pragma: no cover
116
+ pass
117
+
118
+
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ """SDK exception hierarchy for Poelis."""
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class PoelisError(Exception):
9
+ """Base SDK exception."""
10
+
11
+
12
+ class HTTPError(PoelisError):
13
+ """HTTP error from API response."""
14
+
15
+ def __init__(self, status_code: int, message: str | None = None) -> None:
16
+ super().__init__(message or f"HTTP error: {status_code}")
17
+ self.status_code = status_code
18
+ self.message = message or ""
19
+
20
+
21
+ class UnauthorizedError(HTTPError):
22
+ """Raised on 401 Unauthorized."""
23
+
24
+
25
+ class NotFoundError(HTTPError):
26
+ """Raised on 404 Not Found."""
27
+
28
+
29
+ class RateLimitError(HTTPError):
30
+ """Raised on 429 Too Many Requests with optional retry-after seconds."""
31
+
32
+ def __init__(self, status_code: int, message: str | None = None, retry_after_seconds: Optional[float] = None) -> None:
33
+ super().__init__(status_code=status_code, message=message)
34
+ self.retry_after_seconds = retry_after_seconds
35
+
36
+
37
+ class ClientError(HTTPError):
38
+ """Raised on other 4xx errors."""
39
+
40
+
41
+ class ServerError(HTTPError):
42
+ """Raised on 5xx errors."""
43
+
44
+
poelis_sdk/items.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ """Items resource client."""
4
+
5
+ from typing import Generator, Any, Optional, Dict, List
6
+
7
+ from ._transport import Transport
8
+
9
+
10
+ class ItemsClient:
11
+ """Client for item resources (prototype exposes listing iterator only)."""
12
+
13
+ def __init__(self, transport: Transport) -> None:
14
+ self._t = transport
15
+
16
+ def list_by_product(self, *, product_id: str, q: Optional[str] = None, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
17
+ """List items for a product via GraphQL with optional text filter."""
18
+
19
+ query = (
20
+ "query($pid: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
21
+ " items(productId: $pid, q: $q, limit: $limit, offset: $offset) { id name code description productId parentId owner }\n"
22
+ "}"
23
+ )
24
+ variables = {"pid": product_id, "q": q, "limit": int(limit), "offset": int(offset)}
25
+ resp = self._t.graphql(query=query, variables=variables)
26
+ resp.raise_for_status()
27
+ payload = resp.json()
28
+ if "errors" in payload:
29
+ raise RuntimeError(str(payload["errors"]))
30
+ return payload.get("data", {}).get("items", [])
31
+
32
+ def get(self, item_id: str) -> Dict[str, Any]:
33
+ """Get a single item by id via GraphQL."""
34
+
35
+ query = (
36
+ "query($id: ID!) {\n"
37
+ " item(id: $id) { id name code description productId parentId owner }\n"
38
+ "}"
39
+ )
40
+ resp = self._t.graphql(query=query, variables={"id": item_id})
41
+ resp.raise_for_status()
42
+ payload = resp.json()
43
+ if "errors" in payload:
44
+ raise RuntimeError(str(payload["errors"]))
45
+ return payload.get("data", {}).get("item")
46
+
47
+ def iter_all_by_product(self, *, product_id: str, q: Optional[str] = None, page_size: int = 100) -> Generator[dict, None, None]:
48
+ """Iterate items via GraphQL for a given product."""
49
+
50
+ offset = 0
51
+ while True:
52
+ data = self.list_by_product(product_id=product_id, q=q, limit=page_size, offset=offset)
53
+ if not data:
54
+ break
55
+ for item in data:
56
+ yield item
57
+ offset += len(data)
58
+
59
+
poelis_sdk/models.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ """Pydantic models for SDK resources."""
4
+
5
+ from typing import Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class Product(BaseModel):
11
+ """Product resource representation."""
12
+
13
+ id: str = Field(min_length=1)
14
+ name: str = Field(min_length=1)
15
+ workspace_id: Optional[str] = None
16
+
17
+
18
+ class PaginatedProducts(BaseModel):
19
+ """Paginated response for products list."""
20
+
21
+ data: list[Product]
22
+ limit: int
23
+ offset: int
24
+
25
+
poelis_sdk/products.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ """Products resource client."""
4
+
5
+ from typing import Generator, Optional, List
6
+
7
+ from ._transport import Transport
8
+ from .models import PaginatedProducts, Product
9
+
10
+
11
+ class ProductsClient:
12
+ """Client for product resources."""
13
+
14
+ def __init__(self, transport: Transport) -> None:
15
+ """Initialize with shared transport."""
16
+
17
+ self._t = transport
18
+
19
+ def list_by_workspace(self, *, workspace_id: str, q: Optional[str] = None, limit: int = 100, offset: int = 0) -> PaginatedProducts:
20
+ """List products using GraphQL for a given workspace.
21
+
22
+ Args:
23
+ workspace_id: Workspace ID to scope products.
24
+ q: Optional free-text filter.
25
+ limit: Page size.
26
+ offset: Offset for pagination.
27
+ """
28
+
29
+ query = (
30
+ "query($ws: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
31
+ " products(workspaceId: $ws, q: $q, limit: $limit, offset: $offset) { id name code description workspaceId }\n"
32
+ "}"
33
+ )
34
+ variables = {"ws": workspace_id, "q": q, "limit": int(limit), "offset": int(offset)}
35
+ resp = self._t.graphql(query=query, variables=variables)
36
+ resp.raise_for_status()
37
+ payload = resp.json()
38
+ if "errors" in payload:
39
+ raise RuntimeError(str(payload["errors"]))
40
+ rows: List[dict] = payload.get("data", {}).get("products", [])
41
+ return PaginatedProducts(data=[Product(**r) for r in rows], limit=limit, offset=offset)
42
+
43
+ def iter_all_by_workspace(self, *, workspace_id: str, q: Optional[str] = None, page_size: int = 100, start_offset: int = 0) -> Generator[Product, None, None]:
44
+ """Iterate products via GraphQL with offset pagination for a workspace."""
45
+
46
+ offset = start_offset
47
+ while True:
48
+ page = self.list_by_workspace(workspace_id=workspace_id, q=q, limit=page_size, offset=offset)
49
+ if not page.data:
50
+ break
51
+ for product in page.data:
52
+ yield product
53
+ offset += len(page.data)
54
+
55
+
poelis_sdk/search.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ """Search resource client using GraphQL endpoints only."""
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from ._transport import Transport
8
+
9
+
10
+ class SearchClient:
11
+ """Client for /v1/search endpoints (products, items, properties)."""
12
+
13
+ def __init__(self, transport: Transport) -> None:
14
+ self._t = transport
15
+
16
+ def products(self, *, q: str, workspace_id: str, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
17
+ """Search/list products via GraphQL products(workspace_id, q)."""
18
+
19
+ query = (
20
+ "query($ws: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
21
+ " products(workspace_id: $ws, q: $q, limit: $limit, offset: $offset) { id name code description workspace_id }\n"
22
+ "}"
23
+ )
24
+ variables = {"ws": workspace_id, "q": q, "limit": int(limit), "offset": int(offset)}
25
+ resp = self._t.graphql(query=query, variables=variables)
26
+ resp.raise_for_status()
27
+ payload = resp.json()
28
+ if "errors" in payload:
29
+ raise RuntimeError(str(payload["errors"]))
30
+ hits = payload.get("data", {}).get("products", [])
31
+ return {"query": q, "hits": hits, "total": None, "limit": limit, "offset": offset}
32
+
33
+ def items(self, *, q: Optional[str], product_id: str, parent_item_id: Optional[str] = None, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
34
+ """Search/list items via GraphQL items(product_id, q, parent_item_id)."""
35
+
36
+ query = (
37
+ "query($pid: ID!, $q: String, $parent: ID, $limit: Int!, $offset: Int!) {\n"
38
+ " items(productId: $pid, q: $q, parentItemId: $parent, limit: $limit, offset: $offset) { id name code description productId parentId owner }\n"
39
+ "}"
40
+ )
41
+ variables = {"pid": product_id, "q": q, "parent": parent_item_id, "limit": int(limit), "offset": int(offset)}
42
+ resp = self._t.graphql(query=query, variables=variables)
43
+ resp.raise_for_status()
44
+ payload = resp.json()
45
+ if "errors" in payload:
46
+ raise RuntimeError(str(payload["errors"]))
47
+ hits = payload.get("data", {}).get("items", [])
48
+ return {"query": q, "hits": hits, "total": None, "limit": limit, "offset": offset}
49
+
50
+ def properties(self, *, q: str, workspace_id: Optional[str] = None, product_id: Optional[str] = None, item_id: Optional[str] = None, property_type: Optional[str] = None, category: Optional[str] = None, limit: int = 20, offset: int = 0, sort: Optional[str] = None) -> Dict[str, Any]:
51
+ """Search properties via GraphQL search_properties."""
52
+
53
+ query = (
54
+ "query($q: String!, $ws: ID, $pid: ID, $iid: ID, $ptype: String, $cat: String, $limit: Int!, $offset: Int!, $sort: String) {\n"
55
+ " searchProperties(q: $q, workspaceId: $ws, productId: $pid, itemId: $iid, propertyType: $ptype, category: $cat, limit: $limit, offset: $offset, sort: $sort) {\n"
56
+ " query total limit offset processingTimeMs\n"
57
+ " hits { id workspaceId productId itemId propertyType name category numericValue textValue dateValue owner }\n"
58
+ " }\n"
59
+ "}"
60
+ )
61
+ variables: Dict[str, Any] = {
62
+ "q": q,
63
+ "ws": workspace_id,
64
+ "pid": product_id,
65
+ "iid": item_id,
66
+ "ptype": property_type,
67
+ "cat": category,
68
+ "limit": int(limit),
69
+ "offset": int(offset),
70
+ "sort": sort,
71
+ }
72
+ resp = self._t.graphql(query=query, variables=variables)
73
+ resp.raise_for_status()
74
+ payload = resp.json()
75
+ if "errors" in payload:
76
+ raise RuntimeError(str(payload["errors"]))
77
+ data = payload.get("data", {}).get("searchProperties", {})
78
+ # Normalize to match previous REST shape
79
+ return {
80
+ "query": data.get("query", q),
81
+ "hits": data.get("hits", []),
82
+ "total": data.get("total"),
83
+ "limit": data.get("limit", limit),
84
+ "offset": data.get("offset", offset),
85
+ "processing_time_ms": data.get("processingTimeMs", 0),
86
+ }
87
+
88
+
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ """Workspaces GraphQL client."""
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ._transport import Transport
8
+
9
+
10
+ class WorkspacesClient:
11
+ """Client for querying workspaces via GraphQL."""
12
+
13
+ def __init__(self, transport: Transport) -> None:
14
+ self._t = transport
15
+
16
+ def list(self, *, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
17
+ """List workspaces (implicitly scoped by org via auth)."""
18
+
19
+ query = (
20
+ "query($limit: Int!, $offset: Int!) {\n"
21
+ " workspaces(limit: $limit, offset: $offset) { id orgId name projectLimit }\n"
22
+ "}"
23
+ )
24
+ resp = self._t.graphql(query=query, variables={"limit": int(limit), "offset": int(offset)})
25
+ resp.raise_for_status()
26
+ payload = resp.json()
27
+ if "errors" in payload:
28
+ raise RuntimeError(str(payload["errors"]))
29
+ return payload.get("data", {}).get("workspaces", [])
30
+
31
+ def get(self, *, workspace_id: str) -> Optional[Dict[str, Any]]:
32
+ """Get a single workspace by id via GraphQL."""
33
+
34
+ query = (
35
+ "query($id: ID!) {\n"
36
+ " workspace(id: $id) { id orgId name projectLimit }\n"
37
+ "}"
38
+ )
39
+ resp = self._t.graphql(query=query, variables={"id": workspace_id})
40
+ resp.raise_for_status()
41
+ payload = resp.json()
42
+ if "errors" in payload:
43
+ raise RuntimeError(str(payload["errors"]))
44
+ return payload.get("data", {}).get("workspace")
45
+
46
+
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: poelis-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Poelis
5
+ Project-URL: Homepage, https://poelis.ai
6
+ Project-URL: Source, https://github.com/PoelisTechnologies/poelis-python-sdk
7
+ Project-URL: Issues, https://github.com/PoelisTechnologies/poelis-python-sdk/issues
8
+ Author-email: Matteo Braceschi <matteo@poelis.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,client,poelis,sdk
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: build>=1.3.0
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: twine>=6.2.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Poelis Python SDK
25
+
26
+ GraphQL-first Python SDK for Poelis with a focus on great developer experience.
27
+
28
+ ## Quickstart (API key + org ID)
29
+
30
+ ```python
31
+ from poelis_sdk import PoelisClient
32
+
33
+ client = PoelisClient(
34
+ base_url="https://api.poelis.ai", # or your environment
35
+ api_key="poelis_live_A1B2C3...", # Organization Settings → API Keys
36
+ org_id="tenant_uci_001", # same section
37
+ )
38
+
39
+ # Workspaces → Products (GraphQL)
40
+ workspaces = client.workspaces.list(limit=10, offset=0)
41
+ ws_id = workspaces[0]["id"]
42
+
43
+ page = client.products.list_by_workspace(workspace_id=ws_id, limit=10, offset=0)
44
+ print([p.name for p in page.data])
45
+
46
+ # Items for a product (GraphQL)
47
+ pid = page.data[0].id
48
+ items = client.items.list_by_product(product_id=pid, limit=10, offset=0)
49
+ print([i.get("name") for i in items])
50
+
51
+ # Property search (GraphQL)
52
+ props = client.search.properties(q="*", workspace_id=ws_id, limit=10, offset=0)
53
+ print(props["total"], len(props["hits"]))
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ ### Base URL
59
+
60
+ - Local development: `http://localhost:8000`
61
+ - Staging (example): `https://api.staging.poelis.ai`
62
+ - Production (example): `https://api.poelis.ai`
63
+
64
+ Confirm the exact URLs for your environments.
65
+
66
+ Note: Multi-tenancy uses `org_id` for scoping. When using API keys, the SDK sets `X-Poelis-Org` automatically from `org_id`.
67
+
68
+ ### Getting your API key and org ID
69
+
70
+ 1. Navigate to Organization Settings → API Keys.
71
+ 2. Click “Create API key”, choose a name and scopes (read-only by default recommended).
72
+ 3. Copy the full key when shown (it will be visible only once). Keep it secret.
73
+ 4. The `org_id` for your organization is displayed in the same section.
74
+ 5. You can rotate or revoke keys anytime. Prefer storing as env vars:
75
+
76
+ ```bash
77
+ export POELIS_BASE_URL=https://api.poelis.ai
78
+ export POELIS_API_KEY=poelis_live_A1B2C3...
79
+ export POELIS_ORG_ID=tenant_uci_001
80
+ ```
81
+
82
+
83
+ ## Dot-path browser (Notebook UX)
84
+
85
+ The SDK exposes a dot-path browser for easy exploration:
86
+
87
+ ```python
88
+ client.browser # then use TAB to explore
89
+ # client.browser.<workspace>.<product>.<item>.<child>.properties
90
+ ```
91
+
92
+ - Lazy-loaded via GraphQL on-demand.
93
+ - Autocomplete-friendly in Jupyter/VSCode.
94
+
95
+ ## Requirements
96
+
97
+ - Python >= 3.11
98
+ - API base URL reachable from your environment
99
+
100
+ ## License
101
+
102
+ Apache-2.0
@@ -0,0 +1,17 @@
1
+ poelis_sdk/__init__.py,sha256=6pyvKcM6imsuCMm-VIINFNa-hXK9fUmcg86EWshPAlk,126
2
+ poelis_sdk/_transport.py,sha256=RIyBffRR-9uz2iUb46AcRSR8rtsuyjSjFeY-EZ-BtP8,5831
3
+ poelis_sdk/browser.py,sha256=2LblJveGsFMRXCK45S8hM1j3cWowLYYiR_YR7D6dQn8,12409
4
+ poelis_sdk/client.py,sha256=lNjBZ7vVNsHzsHfkpxKdkP9UYjjBuzutS7tZXVSvtQI,3556
5
+ poelis_sdk/exceptions.py,sha256=O7LJn8ZH1ZpaAx7xk7rnxkp6VgJy8ZbSV7yX6arI2tE,1101
6
+ poelis_sdk/items.py,sha256=uKs9LBMP3en_TkUCkzj53KTfGeSjph5E3mfXeAl_YO0,2183
7
+ poelis_sdk/models.py,sha256=OtGuv8w4n6p6X_Qlm8dE2B2Apq10gqwyPopitLz9A1Q,470
8
+ poelis_sdk/products.py,sha256=qyZz-QnLgXTgxAXuTbnLEjIE_GrobtfVKkyIRSe7hxU,2073
9
+ poelis_sdk/search.py,sha256=FSLkczztSQoYfolMRhUAZ7M4wI8PIeE6Ny0c40LGKng,4122
10
+ poelis_sdk/workspaces.py,sha256=kqxIoFlSdaTWdeARjZgTUs_Gy-GF-tkRZCNKCChNsug,1518
11
+ poelis_sdk/.github/workflows/sdk-ci.yml,sha256=hWO-igHeTAsxEJGCueteEQnAEi00GWXJJPa8DWgqhHM,750
12
+ poelis_sdk/.github/workflows/sdk-docs.yml,sha256=bS1uUxOKRMA6TWrmzzJHTokyP0Nt0aJwojcLAgLoEhs,1166
13
+ poelis_sdk/.github/workflows/sdk-publish-testpypi.yml,sha256=FBZcfDrtUijs6rcC8WeIimi9SfgoB8Xm5pTNtcztT44,776
14
+ poelis_sdk-0.1.0.dist-info/METADATA,sha256=l9QximRzNb7kkKnqYLQjTT5xQo7IaOieH-2PqpU9gmQ,3108
15
+ poelis_sdk-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ poelis_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=EEmE_r8wk_pdXB8CWp1LG6sBOl7--hNSS2kV94cI6co,1075
17
+ poelis_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PoelisTechnologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.