poelis-sdk 0.5.4__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,163 @@
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
+ return filtered
81
+
82
+
83
+ def validate_workspace_organization(workspace: Dict[str, Any], expected_org_id: str) -> None:
84
+ """Validate that a workspace belongs to the expected organization.
85
+
86
+ Args:
87
+ workspace: The workspace dictionary to validate.
88
+ expected_org_id: The organization ID that should match.
89
+
90
+ Raises:
91
+ OrganizationValidationError: If the workspace doesn't belong to the expected organization.
92
+ """
93
+ validate_organization_id(workspace, expected_org_id, "workspace")
94
+
95
+
96
+ def validate_product_organization(product: Any, expected_org_id: str) -> None:
97
+ """Validate that a product belongs to the expected organization.
98
+
99
+ Args:
100
+ product: The product object to validate (can be dict or Product model).
101
+ expected_org_id: The organization ID that should match.
102
+
103
+ Raises:
104
+ OrganizationValidationError: If the product doesn't belong to the expected organization.
105
+ """
106
+ # Handle both dict and Product model
107
+ if hasattr(product, 'workspace_id'):
108
+ # Product model - we need to get the workspace to check its org
109
+ # This is a limitation of the current API design
110
+ pass # We'll handle this in the client methods
111
+ elif isinstance(product, dict):
112
+ # Dict format - check if it has orgId directly
113
+ if 'orgId' in product:
114
+ validate_organization_id(product, expected_org_id, "product")
115
+ # If no orgId, we can't validate (backend should handle this)
116
+
117
+
118
+ def validate_item_organization(item: Dict[str, Any], expected_org_id: str) -> None:
119
+ """Validate that an item belongs to the expected organization.
120
+
121
+ Args:
122
+ item: The item dictionary to validate.
123
+ expected_org_id: The organization ID that should match.
124
+
125
+ Raises:
126
+ OrganizationValidationError: If the item doesn't belong to the expected organization.
127
+ """
128
+ validate_organization_id(item, expected_org_id, "item")
129
+
130
+
131
+ def get_organization_context_message(org_id: Optional[str]) -> str:
132
+ """Get a user-friendly message about the current organization context.
133
+
134
+ The SDK now uses user-bound API keys. Organization and workspace access
135
+ are derived on the server from the authenticated user behind the key.
136
+
137
+ Args:
138
+ org_id: Deprecated organization identifier (ignored).
139
+
140
+ Returns:
141
+ A formatted message about the organization/key context.
142
+ """
143
+ if org_id:
144
+ # Kept for backwards compatibility if callers still pass an ID.
145
+ return f"🔒 Organization (derived from key): {org_id}"
146
+ return "🔒 SDK key is user-bound; org and workspaces are derived from the key on the server"
147
+
148
+
149
+ def format_organization_error(error: OrganizationValidationError) -> str:
150
+ """Format an organization validation error for user display.
151
+
152
+ Args:
153
+ error: The organization validation error.
154
+
155
+ Returns:
156
+ A formatted error message.
157
+ """
158
+ return (
159
+ f"❌ Organization Mismatch: {error.message}\n"
160
+ f" Expected: {error.expected_org_id}\n"
161
+ f" Found: {error.actual_org_id or 'None'}\n"
162
+ f" This usually means the data belongs to a different organization."
163
+ )
poelis_sdk/products.py ADDED
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Generator, Optional
4
+
5
+ from ._transport import Transport
6
+ from .models import PaginatedProducts, PaginatedProductVersions, Product, ProductVersion
7
+
8
+ if TYPE_CHECKING:
9
+ from .workspaces import WorkspacesClient
10
+
11
+ """Products resource client."""
12
+
13
+
14
+ class ProductsClient:
15
+ """Client for product resources."""
16
+
17
+ def __init__(self, transport: Transport, workspaces_client: Optional["WorkspacesClient"] = None) -> None:
18
+ """Initialize with shared transport and optional workspaces client."""
19
+
20
+ self._t = transport
21
+ self._workspaces_client = workspaces_client
22
+
23
+ def list_by_workspace(self, *, workspace_id: str, q: Optional[str] = None, limit: int = 100, offset: int = 0) -> PaginatedProducts:
24
+ """List products using GraphQL for a given workspace.
25
+
26
+ Args:
27
+ workspace_id: Workspace ID to scope products.
28
+ q: Optional free-text filter.
29
+ limit: Page size.
30
+ offset: Offset for pagination.
31
+ """
32
+
33
+ query = (
34
+ "query($ws: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
35
+ " products(workspaceId: $ws, q: $q, limit: $limit, offset: $offset) {\n"
36
+ " id\n"
37
+ " name\n"
38
+ " readableId\n"
39
+ " workspaceId\n"
40
+ " baselineVersionNumber\n"
41
+ " }\n"
42
+ "}"
43
+ )
44
+ variables = {"ws": workspace_id, "q": q, "limit": int(limit), "offset": int(offset)}
45
+ resp = self._t.graphql(query=query, variables=variables)
46
+ resp.raise_for_status()
47
+ payload = resp.json()
48
+ if "errors" in payload:
49
+ raise RuntimeError(str(payload["errors"]))
50
+
51
+ products = payload.get("data", {}).get("products", [])
52
+
53
+ return PaginatedProducts(data=[Product(**r) for r in products], limit=limit, offset=offset)
54
+
55
+ def list_product_versions(self, *, product_id: str, limit: int = 50, offset: int = 0) -> PaginatedProductVersions:
56
+ """List versions for a given product.
57
+
58
+ Args:
59
+ product_id: Identifier of the product whose versions should be listed.
60
+ limit: Maximum number of versions to return (currently ignored by backend).
61
+ offset: Offset for pagination (currently ignored by backend).
62
+
63
+ Returns:
64
+ PaginatedProductVersions: Container with version data and pagination info.
65
+
66
+ Raises:
67
+ RuntimeError: If the GraphQL response contains errors.
68
+ """
69
+
70
+ query = (
71
+ "query($pid: ID!) {\n"
72
+ " productVersions(productId: $pid) {\n"
73
+ " productId\n"
74
+ " versionNumber\n"
75
+ " title\n"
76
+ " description\n"
77
+ " createdAt\n"
78
+ " }\n"
79
+ "}"
80
+ )
81
+ variables = {"pid": product_id}
82
+ resp = self._t.graphql(query=query, variables=variables)
83
+ resp.raise_for_status()
84
+ payload = resp.json()
85
+ if "errors" in payload:
86
+ raise RuntimeError(str(payload["errors"]))
87
+
88
+ versions = payload.get("data", {}).get("productVersions", [])
89
+
90
+ return PaginatedProductVersions(data=[ProductVersion(**v) for v in versions], limit=limit, offset=offset)
91
+
92
+ def set_product_baseline_version(self, *, product_id: str, version_number: int) -> Product:
93
+ """Set the baseline version for a product.
94
+
95
+ This wraps the ``setProductBaselineVersion`` GraphQL mutation and returns
96
+ the updated :class:`Product` including its ``baseline_version_number``.
97
+
98
+ Args:
99
+ product_id: Identifier of the product whose baseline should be updated.
100
+ version_number: Version number to mark as baseline.
101
+
102
+ Returns:
103
+ Product: The updated product with the new baseline version number.
104
+
105
+ Raises:
106
+ RuntimeError: If the GraphQL response contains errors.
107
+ """
108
+
109
+ query = (
110
+ "mutation SetBaseline($productId: ID!, $versionNumber: Int!) {\n"
111
+ " setProductBaselineVersion(productId: $productId, versionNumber: $versionNumber) {\n"
112
+ " id\n"
113
+ " name\n"
114
+ " readableId\n"
115
+ " workspaceId\n"
116
+ " baselineVersionNumber\n"
117
+ " }\n"
118
+ "}"
119
+ )
120
+ variables = {"productId": product_id, "versionNumber": int(version_number)}
121
+ resp = self._t.graphql(query=query, variables=variables)
122
+ resp.raise_for_status()
123
+ payload = resp.json()
124
+ if "errors" in payload:
125
+ raise RuntimeError(str(payload["errors"]))
126
+
127
+ product_data = payload.get("data", {}).get("setProductBaselineVersion")
128
+ if product_data is None:
129
+ raise RuntimeError("Malformed GraphQL response: missing 'setProductBaselineVersion' field")
130
+
131
+ return Product(**product_data)
132
+
133
+ 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]:
134
+ """Iterate products via GraphQL with offset pagination for a workspace."""
135
+
136
+ offset = start_offset
137
+ while True:
138
+ page = self.list_by_workspace(workspace_id=workspace_id, q=q, limit=page_size, offset=offset)
139
+ if not page.data:
140
+ break
141
+ for product in page.data:
142
+ yield product
143
+ offset += len(page.data)
144
+
145
+ def iter_all(self, *, q: Optional[str] = None, page_size: int = 100) -> Generator[Product, None, None]:
146
+ """Iterate products across all workspaces.
147
+
148
+ Args:
149
+ q: Optional free-text filter.
150
+ page_size: Page size for each workspace iteration.
151
+
152
+ Raises:
153
+ RuntimeError: If workspaces client is not available.
154
+ """
155
+ if self._workspaces_client is None:
156
+ raise RuntimeError("Workspaces client not available. Cannot iterate across all workspaces.")
157
+
158
+ # Get all workspaces
159
+ workspaces = self._workspaces_client.list(limit=1000, offset=0)
160
+
161
+ for workspace in workspaces:
162
+ workspace_id = workspace['id']
163
+ # Iterate through products in this workspace
164
+ for product in self.iter_all_by_workspace(workspace_id=workspace_id, q=q, page_size=page_size):
165
+ yield product
166
+
167
+
poelis_sdk/search.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from ._transport import Transport
6
+
7
+ """Search resource client using GraphQL endpoints only."""
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(workspaceId, q)."""
18
+
19
+ query = (
20
+ "query($ws: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
21
+ " products(workspaceId: $ws, q: $q, limit: $limit, offset: $offset) { id name workspaceId }\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 productId parentId owner position }\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 value parsedValue 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/versions.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Generator, List, Optional
4
+
5
+ from ._transport import Transport
6
+
7
+ """Versions resource client.
8
+
9
+ Provides read-only access to product versions and their versioned items.
10
+ This client is focused on versioned (snapshot) data; draft mutations should
11
+ continue to go through the non-versioned clients.
12
+ """
13
+
14
+
15
+ class VersionsClient:
16
+ """Client for product version resources."""
17
+
18
+ def __init__(self, transport: Transport) -> None:
19
+ """Initialize the client with shared transport.
20
+
21
+ Args:
22
+ transport: Shared HTTP/GraphQL transport used by the SDK.
23
+ """
24
+
25
+ self._t = transport
26
+
27
+ def list_items(
28
+ self,
29
+ *,
30
+ product_id: str,
31
+ version_number: int,
32
+ q: Optional[str] = None,
33
+ limit: int = 100,
34
+ offset: int = 0,
35
+ ) -> List[Dict[str, Any]]:
36
+ """List versioned items for a specific product version via GraphQL.
37
+
38
+ This method returns the snapshot of items as they were frozen in the
39
+ specified product version. Draft items should be accessed through the
40
+ non-versioned `ItemsClient`.
41
+
42
+ Args:
43
+ product_id: Identifier of the parent product.
44
+ version_number: Version number of the product whose items to list.
45
+ q: Optional free-text filter applied to item name/description.
46
+ limit: Maximum number of items to return.
47
+ offset: Offset for pagination.
48
+
49
+ Returns:
50
+ List of item dictionaries belonging to the given product version.
51
+
52
+ Raises:
53
+ RuntimeError: If the GraphQL response contains errors.
54
+ """
55
+
56
+ query = (
57
+ "query($pid: ID!, $version: VersionInput!, $q: String, $limit: Int!, $offset: Int!) {\n"
58
+ " items(productId: $pid, version: $version, q: $q, limit: $limit, offset: $offset) {\n"
59
+ " id\n"
60
+ " name\n"
61
+ " readableId\n"
62
+ " productId\n"
63
+ " parentId\n"
64
+ " owner\n"
65
+ " position\n"
66
+ " }\n"
67
+ "}"
68
+ )
69
+ variables = {
70
+ "pid": product_id,
71
+ "version": {"productId": product_id, "versionNumber": int(version_number)},
72
+ "q": q,
73
+ "limit": int(limit),
74
+ "offset": int(offset),
75
+ }
76
+ resp = self._t.graphql(query=query, variables=variables)
77
+ resp.raise_for_status()
78
+ payload = resp.json()
79
+ if "errors" in payload:
80
+ raise RuntimeError(str(payload["errors"]))
81
+
82
+ items = payload.get("data", {}).get("items", [])
83
+
84
+ return items
85
+
86
+ def iter_items(
87
+ self,
88
+ *,
89
+ product_id: str,
90
+ version_number: int,
91
+ q: Optional[str] = None,
92
+ page_size: int = 100,
93
+ start_offset: int = 0,
94
+ ) -> Generator[Dict[str, Any], None, None]:
95
+ """Iterate versioned items for a specific product version.
96
+
97
+ Args:
98
+ product_id: Identifier of the parent product.
99
+ version_number: Version number whose items to iterate.
100
+ q: Optional free-text filter applied to item name/description.
101
+ page_size: Page size for each GraphQL request.
102
+ start_offset: Initial offset for pagination.
103
+
104
+ Yields:
105
+ Individual item dictionaries for the given product version.
106
+ """
107
+
108
+ offset = start_offset
109
+ while True:
110
+ page = self.list_items(
111
+ product_id=product_id,
112
+ version_number=version_number,
113
+ q=q,
114
+ limit=page_size,
115
+ offset=offset,
116
+ )
117
+ if not page:
118
+ break
119
+ for item in page:
120
+ yield item
121
+ offset += len(page)
122
+
123
+
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from ._transport import Transport
6
+
7
+ """Workspaces GraphQL client."""
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 readableId }\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
+
30
+ workspaces = payload.get("data", {}).get("workspaces", [])
31
+ return workspaces
32
+
33
+ def get(self, *, workspace_id: str) -> Optional[Dict[str, Any]]:
34
+ """Get a single workspace by id via GraphQL."""
35
+
36
+ query = (
37
+ "query($id: ID!) {\n"
38
+ " workspace(id: $id) { id orgId name readableId }\n"
39
+ "}"
40
+ )
41
+ resp = self._t.graphql(query=query, variables={"id": workspace_id})
42
+ resp.raise_for_status()
43
+ payload = resp.json()
44
+ if "errors" in payload:
45
+ raise RuntimeError(str(payload["errors"]))
46
+
47
+ workspace = payload.get("data", {}).get("workspace")
48
+ return workspace
49
+
50
+