poelis-sdk 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of poelis-sdk might be problematic. Click here for more details.
- poelis_sdk/__init__.py +2 -1
- poelis_sdk/browser.py +5 -1
- poelis_sdk/client.py +6 -2
- poelis_sdk/items.py +29 -6
- poelis_sdk/logging.py +73 -0
- poelis_sdk/org_validation.py +161 -0
- poelis_sdk/products.py +29 -3
- poelis_sdk/search.py +1 -1
- poelis_sdk/workspaces.py +27 -4
- {poelis_sdk-0.1.4.dist-info → poelis_sdk-0.1.6.dist-info}/METADATA +18 -24
- poelis_sdk-0.1.6.dist-info/RECORD +17 -0
- poelis_sdk/.github/workflows/sdk-ci.yml +0 -34
- poelis_sdk/.github/workflows/sdk-docs.yml +0 -55
- poelis_sdk/.github/workflows/sdk-publish-testpypi.yml +0 -31
- poelis_sdk-0.1.4.dist-info/RECORD +0 -18
- {poelis_sdk-0.1.4.dist-info → poelis_sdk-0.1.6.dist-info}/WHEEL +0 -0
- {poelis_sdk-0.1.4.dist-info → poelis_sdk-0.1.6.dist-info}/licenses/LICENSE +0 -0
poelis_sdk/__init__.py
CHANGED
|
@@ -7,8 +7,9 @@ metadata so it stays in sync with ``pyproject.toml`` without manual edits.
|
|
|
7
7
|
from importlib import metadata
|
|
8
8
|
|
|
9
9
|
from .client import PoelisClient
|
|
10
|
+
from .logging import configure_logging, quiet_logging, verbose_logging, debug_logging, get_logger
|
|
10
11
|
|
|
11
|
-
__all__ = ["PoelisClient", "__version__"]
|
|
12
|
+
__all__ = ["PoelisClient", "__version__", "configure_logging", "quiet_logging", "verbose_logging", "debug_logging", "get_logger"]
|
|
12
13
|
|
|
13
14
|
def _resolve_version() -> str:
|
|
14
15
|
"""Return installed package version or a dev fallback.
|
poelis_sdk/browser.py
CHANGED
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any, Dict, List, Optional
|
|
4
4
|
import re
|
|
5
5
|
|
|
6
|
+
from .org_validation import get_organization_context_message
|
|
7
|
+
|
|
6
8
|
"""GraphQL-backed dot-path browser for Poelis SDK.
|
|
7
9
|
|
|
8
10
|
Provides lazy, name-based navigation across workspaces → products → items → child items,
|
|
@@ -204,7 +206,9 @@ class Browser:
|
|
|
204
206
|
return getattr(self._root, attr)
|
|
205
207
|
|
|
206
208
|
def __repr__(self) -> str: # pragma: no cover - notebook UX
|
|
207
|
-
|
|
209
|
+
org_id = self._root._client.org_id
|
|
210
|
+
org_context = get_organization_context_message(org_id) if org_id else "🔒 Organization: Not configured"
|
|
211
|
+
return f"<browser root> ({org_context})"
|
|
208
212
|
|
|
209
213
|
def __getitem__(self, key: str) -> Any: # pragma: no cover - notebook UX
|
|
210
214
|
"""Delegate index-based access to the root node so names work: browser["Workspace Name"]."""
|
poelis_sdk/client.py
CHANGED
|
@@ -10,6 +10,7 @@ from .items import ItemsClient
|
|
|
10
10
|
from .search import SearchClient
|
|
11
11
|
from .workspaces import WorkspacesClient
|
|
12
12
|
from .browser import Browser
|
|
13
|
+
from .logging import quiet_logging
|
|
13
14
|
|
|
14
15
|
"""Core client for the Poelis Python SDK.
|
|
15
16
|
|
|
@@ -54,6 +55,9 @@ class PoelisClient:
|
|
|
54
55
|
timeout_seconds: Network timeout in seconds.
|
|
55
56
|
"""
|
|
56
57
|
|
|
58
|
+
# Configure quiet logging by default for production use
|
|
59
|
+
quiet_logging()
|
|
60
|
+
|
|
57
61
|
self._config = ClientConfig(
|
|
58
62
|
base_url=base_url,
|
|
59
63
|
api_key=api_key,
|
|
@@ -70,10 +74,10 @@ class PoelisClient:
|
|
|
70
74
|
)
|
|
71
75
|
|
|
72
76
|
# Resource clients
|
|
73
|
-
self.
|
|
77
|
+
self.workspaces = WorkspacesClient(self._transport)
|
|
78
|
+
self.products = ProductsClient(self._transport, self.workspaces)
|
|
74
79
|
self.items = ItemsClient(self._transport)
|
|
75
80
|
self.search = SearchClient(self._transport)
|
|
76
|
-
self.workspaces = WorkspacesClient(self._transport)
|
|
77
81
|
self.browser = Browser(self)
|
|
78
82
|
|
|
79
83
|
@classmethod
|
poelis_sdk/items.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Generator, Any, Optional, Dict, List
|
|
4
4
|
|
|
5
5
|
from ._transport import Transport
|
|
6
|
+
from .org_validation import validate_item_organization, filter_by_organization
|
|
6
7
|
|
|
7
8
|
"""Items resource client."""
|
|
8
9
|
|
|
@@ -14,11 +15,14 @@ class ItemsClient:
|
|
|
14
15
|
self._t = transport
|
|
15
16
|
|
|
16
17
|
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
|
+
"""List items for a product via GraphQL with optional text filter.
|
|
19
|
+
|
|
20
|
+
Returns only items that belong to the client's configured organization.
|
|
21
|
+
"""
|
|
18
22
|
|
|
19
23
|
query = (
|
|
20
24
|
"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"
|
|
25
|
+
" items(productId: $pid, q: $q, limit: $limit, offset: $offset) { id name code description productId parentId owner orgId }\n"
|
|
22
26
|
"}"
|
|
23
27
|
)
|
|
24
28
|
variables = {"pid": product_id, "q": q, "limit": int(limit), "offset": int(offset)}
|
|
@@ -27,14 +31,24 @@ class ItemsClient:
|
|
|
27
31
|
payload = resp.json()
|
|
28
32
|
if "errors" in payload:
|
|
29
33
|
raise RuntimeError(str(payload["errors"]))
|
|
30
|
-
|
|
34
|
+
|
|
35
|
+
items = payload.get("data", {}).get("items", [])
|
|
36
|
+
|
|
37
|
+
# Client-side organization filtering as backup protection
|
|
38
|
+
expected_org_id = self._t._org_id
|
|
39
|
+
filtered_items = filter_by_organization(items, expected_org_id, "items")
|
|
40
|
+
|
|
41
|
+
return filtered_items
|
|
31
42
|
|
|
32
43
|
def get(self, item_id: str) -> Dict[str, Any]:
|
|
33
|
-
"""Get a single item by id via GraphQL.
|
|
44
|
+
"""Get a single item by id via GraphQL.
|
|
45
|
+
|
|
46
|
+
Returns the item only if it belongs to the client's configured organization.
|
|
47
|
+
"""
|
|
34
48
|
|
|
35
49
|
query = (
|
|
36
50
|
"query($id: ID!) {\n"
|
|
37
|
-
" item(id: $id) { id name code description productId parentId owner }\n"
|
|
51
|
+
" item(id: $id) { id name code description productId parentId owner orgId }\n"
|
|
38
52
|
"}"
|
|
39
53
|
)
|
|
40
54
|
resp = self._t.graphql(query=query, variables={"id": item_id})
|
|
@@ -42,7 +56,16 @@ class ItemsClient:
|
|
|
42
56
|
payload = resp.json()
|
|
43
57
|
if "errors" in payload:
|
|
44
58
|
raise RuntimeError(str(payload["errors"]))
|
|
45
|
-
|
|
59
|
+
|
|
60
|
+
item = payload.get("data", {}).get("item")
|
|
61
|
+
if item is None:
|
|
62
|
+
raise RuntimeError(f"Item with id '{item_id}' not found")
|
|
63
|
+
|
|
64
|
+
# Validate that the item belongs to the configured organization
|
|
65
|
+
expected_org_id = self._t._org_id
|
|
66
|
+
validate_item_organization(item, expected_org_id)
|
|
67
|
+
|
|
68
|
+
return item
|
|
46
69
|
|
|
47
70
|
def iter_all_by_product(self, *, product_id: str, q: Optional[str] = None, page_size: int = 100) -> Generator[dict, None, None]:
|
|
48
71
|
"""Iterate items via GraphQL for a given product."""
|
poelis_sdk/logging.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Logging configuration for the Poelis SDK.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to configure logging levels for the SDK and its dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_logging(
|
|
12
|
+
level: str = "WARNING",
|
|
13
|
+
disable_httpx_logs: bool = True,
|
|
14
|
+
disable_urllib3_logs: bool = True,
|
|
15
|
+
enable_sdk_logs: bool = False,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Configure logging for the Poelis SDK and its dependencies.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
level: Logging level for the root logger (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
21
|
+
disable_httpx_logs: Whether to disable httpx HTTP request logs.
|
|
22
|
+
disable_urllib3_logs: Whether to disable urllib3 logs.
|
|
23
|
+
enable_sdk_logs: Whether to enable SDK-specific debug logs.
|
|
24
|
+
"""
|
|
25
|
+
# Set root logger level
|
|
26
|
+
numeric_level = getattr(logging, level.upper(), logging.WARNING)
|
|
27
|
+
logging.basicConfig(level=numeric_level)
|
|
28
|
+
|
|
29
|
+
# Configure httpx logging
|
|
30
|
+
if disable_httpx_logs:
|
|
31
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
32
|
+
else:
|
|
33
|
+
logging.getLogger("httpx").setLevel(logging.INFO)
|
|
34
|
+
|
|
35
|
+
# Configure urllib3 logging
|
|
36
|
+
if disable_urllib3_logs:
|
|
37
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
38
|
+
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
|
|
39
|
+
|
|
40
|
+
# Configure SDK logging
|
|
41
|
+
sdk_logger = logging.getLogger("poelis_sdk")
|
|
42
|
+
if enable_sdk_logs:
|
|
43
|
+
sdk_logger.setLevel(logging.DEBUG)
|
|
44
|
+
else:
|
|
45
|
+
sdk_logger.setLevel(logging.WARNING)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_logger(name: str) -> logging.Logger:
|
|
49
|
+
"""Get a logger for the given name.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
name: Logger name, typically __name__.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Logger instance.
|
|
56
|
+
"""
|
|
57
|
+
return logging.getLogger(f"poelis_sdk.{name}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Convenience functions for common logging configurations
|
|
61
|
+
def quiet_logging() -> None:
|
|
62
|
+
"""Configure quiet logging - only show warnings and errors."""
|
|
63
|
+
configure_logging(level="WARNING", disable_httpx_logs=True, disable_urllib3_logs=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verbose_logging() -> None:
|
|
67
|
+
"""Configure verbose logging - show all logs including HTTP requests."""
|
|
68
|
+
configure_logging(level="INFO", disable_httpx_logs=False, disable_urllib3_logs=False, enable_sdk_logs=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def debug_logging() -> None:
|
|
72
|
+
"""Configure debug logging - show everything including SDK debug logs."""
|
|
73
|
+
configure_logging(level="DEBUG", disable_httpx_logs=False, disable_urllib3_logs=False, enable_sdk_logs=True)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Organization validation utilities for the Poelis SDK.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for validating that data belongs to the
|
|
4
|
+
configured organization, ensuring proper multi-tenant isolation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrganizationValidationError(ClientError):
|
|
15
|
+
"""Raised when data doesn't belong to the configured organization."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, expected_org_id: str, actual_org_id: Optional[str] = None) -> None:
|
|
18
|
+
"""Initialize organization validation error.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
message: Error message describing the validation failure.
|
|
22
|
+
expected_org_id: The organization ID that was expected.
|
|
23
|
+
actual_org_id: The organization ID that was found (if any).
|
|
24
|
+
"""
|
|
25
|
+
super().__init__(400, message)
|
|
26
|
+
self.expected_org_id = expected_org_id
|
|
27
|
+
self.actual_org_id = actual_org_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_organization_id(data: Dict[str, Any], expected_org_id: str, data_type: str = "item") -> None:
|
|
31
|
+
"""Validate that data belongs to the expected organization.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
data: The data dictionary to validate.
|
|
35
|
+
expected_org_id: The organization ID that should match.
|
|
36
|
+
data_type: Type of data being validated (for error messages).
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
OrganizationValidationError: If the data doesn't belong to the expected organization.
|
|
40
|
+
"""
|
|
41
|
+
actual_org_id = data.get('orgId')
|
|
42
|
+
|
|
43
|
+
if actual_org_id is None:
|
|
44
|
+
raise OrganizationValidationError(
|
|
45
|
+
f"{data_type.capitalize()} does not have an organization ID",
|
|
46
|
+
expected_org_id,
|
|
47
|
+
actual_org_id
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if actual_org_id != expected_org_id:
|
|
51
|
+
raise OrganizationValidationError(
|
|
52
|
+
f"{data_type.capitalize()} belongs to organization '{actual_org_id}', "
|
|
53
|
+
f"but client is configured for organization '{expected_org_id}'",
|
|
54
|
+
expected_org_id,
|
|
55
|
+
actual_org_id
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def filter_by_organization(data_list: List[Dict[str, Any]], expected_org_id: str, data_type: str = "items") -> List[Dict[str, Any]]:
|
|
60
|
+
"""Filter a list of data to only include items from the expected organization.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
data_list: List of data dictionaries to filter.
|
|
64
|
+
expected_org_id: The organization ID to filter by.
|
|
65
|
+
data_type: Type of data being filtered (for logging).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Filtered list containing only data from the expected organization.
|
|
69
|
+
"""
|
|
70
|
+
filtered = []
|
|
71
|
+
cross_org_count = 0
|
|
72
|
+
|
|
73
|
+
for item in data_list:
|
|
74
|
+
item_org_id = item.get('orgId')
|
|
75
|
+
if item_org_id == expected_org_id:
|
|
76
|
+
filtered.append(item)
|
|
77
|
+
else:
|
|
78
|
+
cross_org_count += 1
|
|
79
|
+
|
|
80
|
+
if cross_org_count > 0:
|
|
81
|
+
# Log warning about cross-org data (but don't fail)
|
|
82
|
+
print(f"⚠️ Warning: Filtered out {cross_org_count} {data_type} from other organizations")
|
|
83
|
+
|
|
84
|
+
return filtered
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_workspace_organization(workspace: Dict[str, Any], expected_org_id: str) -> None:
|
|
88
|
+
"""Validate that a workspace belongs to the expected organization.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
workspace: The workspace dictionary to validate.
|
|
92
|
+
expected_org_id: The organization ID that should match.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
OrganizationValidationError: If the workspace doesn't belong to the expected organization.
|
|
96
|
+
"""
|
|
97
|
+
validate_organization_id(workspace, expected_org_id, "workspace")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_product_organization(product: Any, expected_org_id: str) -> None:
|
|
101
|
+
"""Validate that a product belongs to the expected organization.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
product: The product object to validate (can be dict or Product model).
|
|
105
|
+
expected_org_id: The organization ID that should match.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
OrganizationValidationError: If the product doesn't belong to the expected organization.
|
|
109
|
+
"""
|
|
110
|
+
# Handle both dict and Product model
|
|
111
|
+
if hasattr(product, 'workspace_id'):
|
|
112
|
+
# Product model - we need to get the workspace to check its org
|
|
113
|
+
# This is a limitation of the current API design
|
|
114
|
+
pass # We'll handle this in the client methods
|
|
115
|
+
elif isinstance(product, dict):
|
|
116
|
+
# Dict format - check if it has orgId directly
|
|
117
|
+
if 'orgId' in product:
|
|
118
|
+
validate_organization_id(product, expected_org_id, "product")
|
|
119
|
+
# If no orgId, we can't validate (backend should handle this)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_item_organization(item: Dict[str, Any], expected_org_id: str) -> None:
|
|
123
|
+
"""Validate that an item belongs to the expected organization.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
item: The item dictionary to validate.
|
|
127
|
+
expected_org_id: The organization ID that should match.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
OrganizationValidationError: If the item doesn't belong to the expected organization.
|
|
131
|
+
"""
|
|
132
|
+
validate_organization_id(item, expected_org_id, "item")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_organization_context_message(org_id: str) -> str:
|
|
136
|
+
"""Get a user-friendly message about the current organization context.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
org_id: The current organization ID.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A formatted message about the organization context.
|
|
143
|
+
"""
|
|
144
|
+
return f"🔒 Organization: {org_id}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def format_organization_error(error: OrganizationValidationError) -> str:
|
|
148
|
+
"""Format an organization validation error for user display.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
error: The organization validation error.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
A formatted error message.
|
|
155
|
+
"""
|
|
156
|
+
return (
|
|
157
|
+
f"❌ Organization Mismatch: {error.message}\n"
|
|
158
|
+
f" Expected: {error.expected_org_id}\n"
|
|
159
|
+
f" Found: {error.actual_org_id or 'None'}\n"
|
|
160
|
+
f" This usually means the data belongs to a different organization."
|
|
161
|
+
)
|
poelis_sdk/products.py
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Generator, Optional, List
|
|
3
|
+
from typing import Generator, Optional, List, TYPE_CHECKING
|
|
4
4
|
|
|
5
5
|
from ._transport import Transport
|
|
6
6
|
from .models import PaginatedProducts, Product
|
|
7
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .workspaces import WorkspacesClient
|
|
10
|
+
|
|
8
11
|
"""Products resource client."""
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
class ProductsClient:
|
|
12
15
|
"""Client for product resources."""
|
|
13
16
|
|
|
14
|
-
def __init__(self, transport: Transport) -> None:
|
|
15
|
-
"""Initialize with shared transport."""
|
|
17
|
+
def __init__(self, transport: Transport, workspaces_client: Optional["WorkspacesClient"] = None) -> None:
|
|
18
|
+
"""Initialize with shared transport and optional workspaces client."""
|
|
16
19
|
|
|
17
20
|
self._t = transport
|
|
21
|
+
self._workspaces_client = workspaces_client
|
|
18
22
|
|
|
19
23
|
def list_by_workspace(self, *, workspace_id: str, q: Optional[str] = None, limit: int = 100, offset: int = 0) -> PaginatedProducts:
|
|
20
24
|
"""List products using GraphQL for a given workspace.
|
|
@@ -52,4 +56,26 @@ class ProductsClient:
|
|
|
52
56
|
yield product
|
|
53
57
|
offset += len(page.data)
|
|
54
58
|
|
|
59
|
+
def iter_all(self, *, q: Optional[str] = None, page_size: int = 100) -> Generator[Product, None, None]:
|
|
60
|
+
"""Iterate products across all workspaces.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
q: Optional free-text filter.
|
|
64
|
+
page_size: Page size for each workspace iteration.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
RuntimeError: If workspaces client is not available.
|
|
68
|
+
"""
|
|
69
|
+
if self._workspaces_client is None:
|
|
70
|
+
raise RuntimeError("Workspaces client not available. Cannot iterate across all workspaces.")
|
|
71
|
+
|
|
72
|
+
# Get all workspaces
|
|
73
|
+
workspaces = self._workspaces_client.list(limit=1000, offset=0)
|
|
74
|
+
|
|
75
|
+
for workspace in workspaces:
|
|
76
|
+
workspace_id = workspace['id']
|
|
77
|
+
# Iterate through products in this workspace
|
|
78
|
+
for product in self.iter_all_by_workspace(workspace_id=workspace_id, q=q, page_size=page_size):
|
|
79
|
+
yield product
|
|
80
|
+
|
|
55
81
|
|
poelis_sdk/search.py
CHANGED
|
@@ -54,7 +54,7 @@ class SearchClient:
|
|
|
54
54
|
"query($q: String!, $ws: ID, $pid: ID, $iid: ID, $ptype: String, $cat: String, $limit: Int!, $offset: Int!, $sort: String) {\n"
|
|
55
55
|
" searchProperties(q: $q, workspaceId: $ws, productId: $pid, itemId: $iid, propertyType: $ptype, category: $cat, limit: $limit, offset: $offset, sort: $sort) {\n"
|
|
56
56
|
" query total limit offset processingTimeMs\n"
|
|
57
|
-
" hits { id workspaceId productId itemId propertyType name category
|
|
57
|
+
" hits { id workspaceId productId itemId propertyType name category value owner }\n"
|
|
58
58
|
" }\n"
|
|
59
59
|
"}"
|
|
60
60
|
)
|
poelis_sdk/workspaces.py
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any, Dict, List, Optional
|
|
4
4
|
|
|
5
5
|
from ._transport import Transport
|
|
6
|
+
from .org_validation import validate_workspace_organization, filter_by_organization
|
|
6
7
|
|
|
7
8
|
"""Workspaces GraphQL client."""
|
|
8
9
|
|
|
@@ -14,7 +15,10 @@ class WorkspacesClient:
|
|
|
14
15
|
self._t = transport
|
|
15
16
|
|
|
16
17
|
def list(self, *, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
|
17
|
-
"""List workspaces (implicitly scoped by org via auth).
|
|
18
|
+
"""List workspaces (implicitly scoped by org via auth).
|
|
19
|
+
|
|
20
|
+
Returns only workspaces that belong to the client's configured organization.
|
|
21
|
+
"""
|
|
18
22
|
|
|
19
23
|
query = (
|
|
20
24
|
"query($limit: Int!, $offset: Int!) {\n"
|
|
@@ -26,10 +30,20 @@ class WorkspacesClient:
|
|
|
26
30
|
payload = resp.json()
|
|
27
31
|
if "errors" in payload:
|
|
28
32
|
raise RuntimeError(str(payload["errors"]))
|
|
29
|
-
|
|
33
|
+
|
|
34
|
+
workspaces = payload.get("data", {}).get("workspaces", [])
|
|
35
|
+
|
|
36
|
+
# Client-side organization filtering as backup protection
|
|
37
|
+
expected_org_id = self._t._org_id
|
|
38
|
+
filtered_workspaces = filter_by_organization(workspaces, expected_org_id, "workspaces")
|
|
39
|
+
|
|
40
|
+
return filtered_workspaces
|
|
30
41
|
|
|
31
42
|
def get(self, *, workspace_id: str) -> Optional[Dict[str, Any]]:
|
|
32
|
-
"""Get a single workspace by id via GraphQL.
|
|
43
|
+
"""Get a single workspace by id via GraphQL.
|
|
44
|
+
|
|
45
|
+
Returns the workspace only if it belongs to the client's configured organization.
|
|
46
|
+
"""
|
|
33
47
|
|
|
34
48
|
query = (
|
|
35
49
|
"query($id: ID!) {\n"
|
|
@@ -41,6 +55,15 @@ class WorkspacesClient:
|
|
|
41
55
|
payload = resp.json()
|
|
42
56
|
if "errors" in payload:
|
|
43
57
|
raise RuntimeError(str(payload["errors"]))
|
|
44
|
-
|
|
58
|
+
|
|
59
|
+
workspace = payload.get("data", {}).get("workspace")
|
|
60
|
+
if workspace is None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Validate that the workspace belongs to the configured organization
|
|
64
|
+
expected_org_id = self._t._org_id
|
|
65
|
+
validate_workspace_organization(workspace, expected_org_id)
|
|
66
|
+
|
|
67
|
+
return workspace
|
|
45
68
|
|
|
46
69
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: poelis-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Official Python SDK for Poelis
|
|
5
|
-
Project-URL: Homepage, https://poelis.
|
|
5
|
+
Project-URL: Homepage, https://poelis.com
|
|
6
6
|
Project-URL: Source, https://github.com/PoelisTechnologies/poelis-python-sdk
|
|
7
7
|
Project-URL: Issues, https://github.com/PoelisTechnologies/poelis-python-sdk/issues
|
|
8
8
|
Author-email: Matteo Braceschi <matteo@poelis.com>
|
|
@@ -23,7 +23,15 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
|
|
24
24
|
# Poelis Python SDK
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Python SDK for Poelis.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -U poelis-sdk
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.11+.
|
|
27
35
|
|
|
28
36
|
## Quickstart (API key + org ID)
|
|
29
37
|
|
|
@@ -33,40 +41,27 @@ from poelis_sdk import PoelisClient
|
|
|
33
41
|
client = PoelisClient(
|
|
34
42
|
api_key="poelis_live_A1B2C3...", # Organization Settings → API Keys
|
|
35
43
|
org_id="tenant_uci_001", # same section
|
|
36
|
-
# base_url defaults to https://api.poelis.ai
|
|
37
44
|
)
|
|
38
45
|
|
|
39
|
-
# Workspaces → Products
|
|
46
|
+
# Workspaces → Products
|
|
40
47
|
workspaces = client.workspaces.list(limit=10, offset=0)
|
|
41
48
|
ws_id = workspaces[0]["id"]
|
|
42
49
|
|
|
43
50
|
page = client.products.list_by_workspace(workspace_id=ws_id, limit=10, offset=0)
|
|
44
51
|
print([p.name for p in page.data])
|
|
45
52
|
|
|
46
|
-
# Items for a product
|
|
53
|
+
# Items for a product
|
|
47
54
|
pid = page.data[0].id
|
|
48
55
|
items = client.items.list_by_product(product_id=pid, limit=10, offset=0)
|
|
49
56
|
print([i.get("name") for i in items])
|
|
50
57
|
|
|
51
|
-
# Property search
|
|
58
|
+
# Property search
|
|
52
59
|
props = client.search.properties(q="*", workspace_id=ws_id, limit=10, offset=0)
|
|
53
60
|
print(props["total"], len(props["hits"]))
|
|
54
61
|
```
|
|
55
62
|
|
|
56
63
|
## Configuration
|
|
57
64
|
|
|
58
|
-
### Base URL
|
|
59
|
-
|
|
60
|
-
The SDK defaults to the production API (`https://api.poelis.ai`). You can override this for different environments:
|
|
61
|
-
|
|
62
|
-
- Local development: `base_url="http://localhost:8000"`
|
|
63
|
-
- Staging (example): `base_url="https://api.staging.poelis.ai"`
|
|
64
|
-
- Production (default): No need to specify, uses `https://api.poelis.ai`
|
|
65
|
-
|
|
66
|
-
Confirm the exact URLs for your environments.
|
|
67
|
-
|
|
68
|
-
Note: Multi-tenancy uses `org_id` for scoping. When using API keys, the SDK sets `X-Poelis-Org` automatically from `org_id`.
|
|
69
|
-
|
|
70
65
|
### Getting your API key and org ID
|
|
71
66
|
|
|
72
67
|
1. Navigate to Organization Settings → API Keys.
|
|
@@ -77,8 +72,8 @@ Note: Multi-tenancy uses `org_id` for scoping. When using API keys, the SDK sets
|
|
|
77
72
|
|
|
78
73
|
```bash
|
|
79
74
|
export POELIS_API_KEY=poelis_live_A1B2C3...
|
|
80
|
-
export POELIS_ORG_ID=
|
|
81
|
-
# POELIS_BASE_URL is optional - defaults to
|
|
75
|
+
export POELIS_ORG_ID=tenant_id_001
|
|
76
|
+
# POELIS_BASE_URL is optional - defaults to the managed GCP endpoint
|
|
82
77
|
```
|
|
83
78
|
|
|
84
79
|
|
|
@@ -91,8 +86,7 @@ client.browser # then use TAB to explore
|
|
|
91
86
|
# client.browser.<workspace>.<product>.<item>.<child>.properties
|
|
92
87
|
```
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
- Autocomplete-friendly in Jupyter/VSCode.
|
|
89
|
+
See the example notebook in `notebooks/try_poelis_sdk.ipynb` for an end-to-end walkthrough (authentication, listing workspaces/products/items, and simple search queries).
|
|
96
90
|
|
|
97
91
|
## Requirements
|
|
98
92
|
|
|
@@ -101,4 +95,4 @@ client.browser # then use TAB to explore
|
|
|
101
95
|
|
|
102
96
|
## License
|
|
103
97
|
|
|
104
|
-
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
poelis_sdk/__init__.py,sha256=vRKuvnMGtq2_6SYDPNpckSPYXTgMDD1vBAfZ1bXlHL0,924
|
|
2
|
+
poelis_sdk/_transport.py,sha256=F5EX0EJFHJPAE638nKzlX5zLSU6FIMzMemqgh05_V6U,6061
|
|
3
|
+
poelis_sdk/auth0.py,sha256=VDZHCv9YpsW55H-PLINKMq74UhevP6OWyBHQyEFIpvw,3163
|
|
4
|
+
poelis_sdk/browser.py,sha256=i3j2G2eDCD8JPPtRnU-z_AOup0aBGIKisilVmJuqb-w,12645
|
|
5
|
+
poelis_sdk/client.py,sha256=10__5po-foX36ZCCduQmzdoh9NNS320kyaqztUNtPvo,3872
|
|
6
|
+
poelis_sdk/exceptions.py,sha256=qX5kpAr8ozJUOW-CNhmspWVIE-bvUZT_PUnimYuBxNY,1101
|
|
7
|
+
poelis_sdk/items.py,sha256=laRHVCaTkMmNQyXs6_IVrsyj6whMxSN8Qi6aPQ4dN00,3036
|
|
8
|
+
poelis_sdk/logging.py,sha256=zmg8Us-7qjDl0n_NfOSvDolLopy7Dc_hQ-pcrC63dY8,2442
|
|
9
|
+
poelis_sdk/models.py,sha256=zKbqHkK2xOdkqWUQlmu-BZ0Zyj8uC2d10PK69f3QUHo,470
|
|
10
|
+
poelis_sdk/org_validation.py,sha256=H0jyer8qEAttIW-WC0sS4_32GoC8fXB0rjbn1_rZwQ0,5803
|
|
11
|
+
poelis_sdk/products.py,sha256=XEi028Gqfpp887gBgvSA5Punzae4AfaorwkzuLwX890,3232
|
|
12
|
+
poelis_sdk/search.py,sha256=JYLz4yV3GZPlif05OqYK2xPoAD1b4XKTmriil4NHTOs,4095
|
|
13
|
+
poelis_sdk/workspaces.py,sha256=hpmRl-Hswr4YDvObQdyVpegIYjUWno7A_BiVBz-AQGc,2383
|
|
14
|
+
poelis_sdk-0.1.6.dist-info/METADATA,sha256=xcvgrU6sH4VGqsceIf4Q0V-5-0IKbFOmMfVo_OsLbrM,2813
|
|
15
|
+
poelis_sdk-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
poelis_sdk-0.1.6.dist-info/licenses/LICENSE,sha256=EEmE_r8wk_pdXB8CWp1LG6sBOl7--hNSS2kV94cI6co,1075
|
|
17
|
+
poelis_sdk-0.1.6.dist-info/RECORD,,
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
poelis_sdk/__init__.py,sha256=QzPuy0lsmF0JEOrcnHDWsUfR_WCO1XpegwrYpSGyUVI,738
|
|
2
|
-
poelis_sdk/_transport.py,sha256=F5EX0EJFHJPAE638nKzlX5zLSU6FIMzMemqgh05_V6U,6061
|
|
3
|
-
poelis_sdk/auth0.py,sha256=VDZHCv9YpsW55H-PLINKMq74UhevP6OWyBHQyEFIpvw,3163
|
|
4
|
-
poelis_sdk/browser.py,sha256=zyMoNqFCvKZDV4ZSFVk2N1-HHiq80gzmkGN3uRo1CuM,12409
|
|
5
|
-
poelis_sdk/client.py,sha256=Sr05go8eNpEXswWAhomQuSakE5Oai_kUsGDwHgPnnLY,3731
|
|
6
|
-
poelis_sdk/exceptions.py,sha256=qX5kpAr8ozJUOW-CNhmspWVIE-bvUZT_PUnimYuBxNY,1101
|
|
7
|
-
poelis_sdk/items.py,sha256=uFm-fu16QUOsVnlnEDF012zpgvySlN9N0SXMwIWXeOw,2183
|
|
8
|
-
poelis_sdk/models.py,sha256=zKbqHkK2xOdkqWUQlmu-BZ0Zyj8uC2d10PK69f3QUHo,470
|
|
9
|
-
poelis_sdk/products.py,sha256=Byc5XBNruIO-vAGxDom0lRWA3xxD6korMhoajPz83R4,2073
|
|
10
|
-
poelis_sdk/search.py,sha256=YxtjR8AD6tOeVNTVWc8J_l9YkVu2f-EnC_udkMAeFiM,4122
|
|
11
|
-
poelis_sdk/workspaces.py,sha256=LNVt73nqdssNx42_YB_V5Qp35kEdFn9rNBYmEjpM7vk,1518
|
|
12
|
-
poelis_sdk/.github/workflows/sdk-ci.yml,sha256=hWO-igHeTAsxEJGCueteEQnAEi00GWXJJPa8DWgqhHM,750
|
|
13
|
-
poelis_sdk/.github/workflows/sdk-docs.yml,sha256=bS1uUxOKRMA6TWrmzzJHTokyP0Nt0aJwojcLAgLoEhs,1166
|
|
14
|
-
poelis_sdk/.github/workflows/sdk-publish-testpypi.yml,sha256=FBZcfDrtUijs6rcC8WeIimi9SfgoB8Xm5pTNtcztT44,776
|
|
15
|
-
poelis_sdk-0.1.4.dist-info/METADATA,sha256=5vzMFlh7cIRQggWa8fRyoC1ao9V-JsP-C2_XgrFx2uM,3280
|
|
16
|
-
poelis_sdk-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
17
|
-
poelis_sdk-0.1.4.dist-info/licenses/LICENSE,sha256=EEmE_r8wk_pdXB8CWp1LG6sBOl7--hNSS2kV94cI6co,1075
|
|
18
|
-
poelis_sdk-0.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|