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.
- poelis_sdk/.github/workflows/sdk-ci.yml +34 -0
- poelis_sdk/.github/workflows/sdk-docs.yml +55 -0
- poelis_sdk/.github/workflows/sdk-publish-testpypi.yml +31 -0
- poelis_sdk/__init__.py +8 -0
- poelis_sdk/_transport.py +141 -0
- poelis_sdk/browser.py +318 -0
- poelis_sdk/client.py +118 -0
- poelis_sdk/exceptions.py +44 -0
- poelis_sdk/items.py +59 -0
- poelis_sdk/models.py +25 -0
- poelis_sdk/products.py +55 -0
- poelis_sdk/search.py +88 -0
- poelis_sdk/workspaces.py +46 -0
- poelis_sdk-0.1.0.dist-info/METADATA +102 -0
- poelis_sdk-0.1.0.dist-info/RECORD +17 -0
- poelis_sdk-0.1.0.dist-info/WHEEL +4 -0
- poelis_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
poelis_sdk/_transport.py
ADDED
|
@@ -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
|
+
|
poelis_sdk/exceptions.py
ADDED
|
@@ -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
|
+
|
poelis_sdk/workspaces.py
ADDED
|
@@ -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,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.
|