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.
- poelis_sdk/__init__.py +30 -0
- poelis_sdk/_transport.py +147 -0
- poelis_sdk/browser.py +1998 -0
- poelis_sdk/change_tracker.py +769 -0
- poelis_sdk/client.py +204 -0
- poelis_sdk/exceptions.py +44 -0
- poelis_sdk/items.py +121 -0
- poelis_sdk/logging.py +73 -0
- poelis_sdk/models.py +183 -0
- poelis_sdk/org_validation.py +163 -0
- poelis_sdk/products.py +167 -0
- poelis_sdk/search.py +88 -0
- poelis_sdk/versions.py +123 -0
- poelis_sdk/workspaces.py +50 -0
- poelis_sdk-0.5.4.dist-info/METADATA +113 -0
- poelis_sdk-0.5.4.dist-info/RECORD +18 -0
- poelis_sdk-0.5.4.dist-info/WHEEL +4 -0
- poelis_sdk-0.5.4.dist-info/licenses/LICENSE +21 -0
poelis_sdk/client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, HttpUrl
|
|
7
|
+
|
|
8
|
+
from ._transport import Transport
|
|
9
|
+
from .browser import Browser
|
|
10
|
+
from .change_tracker import PropertyChangeTracker
|
|
11
|
+
from .items import ItemsClient
|
|
12
|
+
from .logging import quiet_logging
|
|
13
|
+
from .products import ProductsClient
|
|
14
|
+
from .search import SearchClient
|
|
15
|
+
from .workspaces import WorkspacesClient
|
|
16
|
+
from .versions import VersionsClient
|
|
17
|
+
|
|
18
|
+
"""Core client for the Poelis Python SDK.
|
|
19
|
+
|
|
20
|
+
This module exposes the `PoelisClient` which configures base URL, authentication,
|
|
21
|
+
tenant scoping, and provides accessors for resource clients. The initial
|
|
22
|
+
implementation is sync-first and keeps the transport layer swappable for
|
|
23
|
+
future async parity.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClientConfig(BaseModel):
|
|
28
|
+
"""Configuration for `PoelisClient`.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
base_url: Base URL of the Poelis API.
|
|
32
|
+
api_key: API key used for authentication.
|
|
33
|
+
timeout_seconds: Request timeout in seconds.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
base_url: HttpUrl = Field(default="https://poelis-be-py-753618215333.europe-west1.run.app")
|
|
37
|
+
api_key: str = Field(min_length=1)
|
|
38
|
+
timeout_seconds: float = 30.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PoelisClient:
|
|
42
|
+
"""Synchronous Poelis SDK client.
|
|
43
|
+
|
|
44
|
+
Provides access to resource-specific clients (e.g., `products`, `items`).
|
|
45
|
+
This prototype only validates configuration and exposes placeholders for
|
|
46
|
+
resource accessors to unblock incremental development.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
api_key: str,
|
|
52
|
+
base_url: str = "https://poelis-be-py-753618215333.europe-west1.run.app",
|
|
53
|
+
timeout_seconds: float = 30.0,
|
|
54
|
+
org_id: Optional[str] = None,
|
|
55
|
+
enable_change_detection: bool = True,
|
|
56
|
+
baseline_file: Optional[str] = None,
|
|
57
|
+
log_file: Optional[str] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initialize the client with API endpoint and credentials.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
api_key: API key for API authentication.
|
|
63
|
+
base_url: Base URL of the Poelis API. Defaults to production.
|
|
64
|
+
timeout_seconds: Network timeout in seconds.
|
|
65
|
+
org_id: Deprecated, ignored parameter kept for backwards compatibility.
|
|
66
|
+
enable_change_detection: If True, enables automatic warnings when property
|
|
67
|
+
values change between accesses. When True, defaults to using
|
|
68
|
+
`.poelis/baseline.json` for baseline_file and `poelis_changes.log` for
|
|
69
|
+
log_file if not explicitly provided. Defaults to False.
|
|
70
|
+
baseline_file: Optional path to JSON file for persistent baseline storage.
|
|
71
|
+
If provided, property baselines will be saved and loaded between script runs.
|
|
72
|
+
If enable_change_detection is True and this is None, defaults to
|
|
73
|
+
`.poelis/baseline.json`. Defaults to None (in-memory only).
|
|
74
|
+
log_file: Optional path to log file for recording property changes.
|
|
75
|
+
Changes will be appended to this file. If enable_change_detection is True
|
|
76
|
+
and this is None, defaults to `poelis_changes.log`. Defaults to None
|
|
77
|
+
(no file logging).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# Configure quiet logging by default for production use
|
|
81
|
+
quiet_logging()
|
|
82
|
+
|
|
83
|
+
self._config = ClientConfig(
|
|
84
|
+
base_url=base_url,
|
|
85
|
+
api_key=api_key,
|
|
86
|
+
timeout_seconds=timeout_seconds,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Shared transport
|
|
90
|
+
self._transport = Transport(
|
|
91
|
+
base_url=str(self._config.base_url),
|
|
92
|
+
api_key=self._config.api_key,
|
|
93
|
+
timeout_seconds=self._config.timeout_seconds,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Auto-configure baseline_file and log_file if change detection is enabled
|
|
97
|
+
if enable_change_detection:
|
|
98
|
+
if baseline_file is None:
|
|
99
|
+
baseline_file = ".poelis/baseline.json"
|
|
100
|
+
if log_file is None:
|
|
101
|
+
log_file = "poelis_changes.log"
|
|
102
|
+
|
|
103
|
+
# Property change tracking
|
|
104
|
+
self._change_tracker = PropertyChangeTracker(
|
|
105
|
+
enabled=enable_change_detection,
|
|
106
|
+
baseline_file=baseline_file,
|
|
107
|
+
log_file=log_file,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Resource clients
|
|
111
|
+
self.workspaces = WorkspacesClient(self._transport)
|
|
112
|
+
self.products = ProductsClient(self._transport, self.workspaces)
|
|
113
|
+
self.items = ItemsClient(self._transport)
|
|
114
|
+
self.versions = VersionsClient(self._transport)
|
|
115
|
+
self.search = SearchClient(self._transport)
|
|
116
|
+
self.browser = Browser(self)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_env(cls) -> "PoelisClient":
|
|
120
|
+
"""Construct a client using environment variables.
|
|
121
|
+
|
|
122
|
+
Expected variables:
|
|
123
|
+
- POELIS_BASE_URL (optional, defaults to managed GCP endpoint)
|
|
124
|
+
- POELIS_API_KEY
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
base_url = os.environ.get("POELIS_BASE_URL", "https://poelis-be-py-753618215333.europe-west1.run.app")
|
|
128
|
+
api_key = os.environ.get("POELIS_API_KEY")
|
|
129
|
+
|
|
130
|
+
if not api_key:
|
|
131
|
+
raise ValueError("POELIS_API_KEY must be set")
|
|
132
|
+
|
|
133
|
+
return cls(api_key=api_key, base_url=base_url)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def base_url(self) -> str:
|
|
137
|
+
"""Return the configured base URL as a string."""
|
|
138
|
+
|
|
139
|
+
return str(self._config.base_url)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def org_id(self) -> Optional[str]:
|
|
143
|
+
"""Return the configured organization id if any.
|
|
144
|
+
|
|
145
|
+
Note:
|
|
146
|
+
This property is deprecated and always returns ``None``. The backend
|
|
147
|
+
now derives organization and workspace access from the API key
|
|
148
|
+
itself, so explicit org selection on the client is no longer used.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def enable_change_detection(self) -> bool:
|
|
155
|
+
"""Get whether property change detection is enabled.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
bool: True if change detection is enabled, False otherwise.
|
|
159
|
+
"""
|
|
160
|
+
return self._change_tracker.is_enabled()
|
|
161
|
+
|
|
162
|
+
@enable_change_detection.setter
|
|
163
|
+
def enable_change_detection(self, value: bool) -> None:
|
|
164
|
+
"""Enable or disable property change detection.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
value: True to enable, False to disable.
|
|
168
|
+
"""
|
|
169
|
+
if value:
|
|
170
|
+
self._change_tracker.enable()
|
|
171
|
+
else:
|
|
172
|
+
self._change_tracker.disable()
|
|
173
|
+
|
|
174
|
+
def clear_property_baselines(self) -> None:
|
|
175
|
+
"""Clear all recorded property baseline values.
|
|
176
|
+
|
|
177
|
+
This resets the change tracking state, so all properties will be
|
|
178
|
+
treated as new on their next access.
|
|
179
|
+
"""
|
|
180
|
+
self._change_tracker.clear_baselines()
|
|
181
|
+
|
|
182
|
+
def get_changed_properties(self) -> Dict[str, Dict[str, Any]]:
|
|
183
|
+
"""Get information about properties that have changed.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Dict[str, Dict[str, Any]]: Dictionary mapping property_id to change info.
|
|
187
|
+
Currently returns empty dict as change tracking is per-access.
|
|
188
|
+
"""
|
|
189
|
+
return self._change_tracker.get_changed_properties()
|
|
190
|
+
|
|
191
|
+
def write_change_log(self) -> None:
|
|
192
|
+
"""Write all changes detected in this session to the log file.
|
|
193
|
+
|
|
194
|
+
This should be called at the end of a script run to ensure all changes
|
|
195
|
+
are logged, even if warnings were suppressed. If no log file is configured,
|
|
196
|
+
this method does nothing.
|
|
197
|
+
"""
|
|
198
|
+
self._change_tracker.write_change_log()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Deprecated: # pragma: no cover
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
poelis_sdk/exceptions.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
"""SDK exception hierarchy for Poelis."""
|
|
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,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Generator, List, Optional
|
|
4
|
+
|
|
5
|
+
from ._transport import Transport
|
|
6
|
+
|
|
7
|
+
"""Items resource client."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ItemsClient:
|
|
11
|
+
"""Client for draft item resources.
|
|
12
|
+
|
|
13
|
+
This client is intended for accessing the current draft view of items,
|
|
14
|
+
i.e., items that are not bound to a specific product version. Versioned
|
|
15
|
+
(snapshot) items for a given product version should be accessed via the
|
|
16
|
+
`VersionsClient`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, transport: Transport) -> None:
|
|
20
|
+
"""Initialize the client with shared transport.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
transport: Shared HTTP/GraphQL transport used by the SDK.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
self._t = transport
|
|
27
|
+
|
|
28
|
+
def list_by_product(self, *, product_id: str, q: Optional[str] = None, limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
|
|
29
|
+
"""List draft items for a product via GraphQL with optional text filter.
|
|
30
|
+
|
|
31
|
+
This method is intended to return the current draft state of items for
|
|
32
|
+
the given product (items without a bound product version). To retrieve
|
|
33
|
+
historical/versioned items, use `VersionsClient.list_items`.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
product_id: Identifier of the parent product.
|
|
37
|
+
q: Optional free-text filter applied to item name/description.
|
|
38
|
+
limit: Maximum number of items to return.
|
|
39
|
+
offset: Offset for pagination.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
List of draft item dictionaries belonging to the client's
|
|
43
|
+
configured organization.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RuntimeError: If the GraphQL response contains errors.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
query = (
|
|
50
|
+
"query($pid: ID!, $q: String, $limit: Int!, $offset: Int!) {\n"
|
|
51
|
+
" items(productId: $pid, q: $q, limit: $limit, offset: $offset) { id name readableId productId parentId owner position }\n"
|
|
52
|
+
"}"
|
|
53
|
+
)
|
|
54
|
+
variables = {"pid": product_id, "q": q, "limit": int(limit), "offset": int(offset)}
|
|
55
|
+
resp = self._t.graphql(query=query, variables=variables)
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
payload = resp.json()
|
|
58
|
+
if "errors" in payload:
|
|
59
|
+
raise RuntimeError(str(payload["errors"]))
|
|
60
|
+
|
|
61
|
+
items = payload.get("data", {}).get("items", [])
|
|
62
|
+
|
|
63
|
+
return items
|
|
64
|
+
|
|
65
|
+
def get(self, item_id: str) -> Dict[str, Any]:
|
|
66
|
+
"""Get a single draft item by identifier via GraphQL.
|
|
67
|
+
|
|
68
|
+
Returns the item only if it belongs to the client's configured
|
|
69
|
+
organization. The returned representation reflects the current draft
|
|
70
|
+
state, not a specific historical product version.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
item_id: Identifier of the item to retrieve.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Dictionary representing the draft item.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
RuntimeError: If the GraphQL response contains errors or the item
|
|
80
|
+
cannot be found.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
query = (
|
|
84
|
+
"query($id: ID!) {\n"
|
|
85
|
+
" item(id: $id) { id name readableId productId parentId owner position }\n"
|
|
86
|
+
"}"
|
|
87
|
+
)
|
|
88
|
+
resp = self._t.graphql(query=query, variables={"id": item_id})
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
payload = resp.json()
|
|
91
|
+
if "errors" in payload:
|
|
92
|
+
raise RuntimeError(str(payload["errors"]))
|
|
93
|
+
|
|
94
|
+
item = payload.get("data", {}).get("item")
|
|
95
|
+
if item is None:
|
|
96
|
+
raise RuntimeError(f"Item with id '{item_id}' not found")
|
|
97
|
+
|
|
98
|
+
return item
|
|
99
|
+
|
|
100
|
+
def iter_all_by_product(self, *, product_id: str, q: Optional[str] = None, page_size: int = 100) -> Generator[dict, None, None]:
|
|
101
|
+
"""Iterate draft items via GraphQL for a given product.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
product_id: Identifier of the parent product.
|
|
105
|
+
q: Optional free-text filter applied to item name/description.
|
|
106
|
+
page_size: Page size for each GraphQL request.
|
|
107
|
+
|
|
108
|
+
Yields:
|
|
109
|
+
Individual draft item dictionaries.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
offset = 0
|
|
113
|
+
while True:
|
|
114
|
+
data = self.list_by_product(product_id=product_id, q=q, limit=page_size, offset=offset)
|
|
115
|
+
if not data:
|
|
116
|
+
break
|
|
117
|
+
for item in data:
|
|
118
|
+
yield item
|
|
119
|
+
offset += len(data)
|
|
120
|
+
|
|
121
|
+
|
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)
|
poelis_sdk/models.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
"""Pydantic models for SDK resources."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Product(BaseModel):
|
|
13
|
+
"""Product resource representation."""
|
|
14
|
+
|
|
15
|
+
id: str = Field(min_length=1)
|
|
16
|
+
name: str = Field(min_length=1)
|
|
17
|
+
readableId: Optional[str] = None
|
|
18
|
+
workspaceId: Optional[str] = None
|
|
19
|
+
baseline_version_number: Optional[int] = Field(
|
|
20
|
+
alias="baselineVersionNumber",
|
|
21
|
+
default=None,
|
|
22
|
+
)
|
|
23
|
+
code: Optional[str] = None
|
|
24
|
+
description: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProductVersion(BaseModel):
|
|
28
|
+
"""Product version resource representation.
|
|
29
|
+
|
|
30
|
+
Represents a frozen snapshot of a product at a specific version number.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
product_id: Identifier of the parent product.
|
|
34
|
+
version_number: Monotonic version number for the product.
|
|
35
|
+
title: Human-friendly title for this version.
|
|
36
|
+
description: Optional free-text description of the version.
|
|
37
|
+
created_by: Optional identifier of the user who created the version (not currently queried from GraphQL).
|
|
38
|
+
created_at: Timestamp when the version was created.
|
|
39
|
+
updated_at: Optional timestamp of the last update to the version (not in GraphQL schema yet).
|
|
40
|
+
org_id: Optional identifier of the owning organization (not in GraphQL schema yet).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
product_id: str = Field(alias="productId", min_length=1)
|
|
44
|
+
version_number: int = Field(alias="versionNumber")
|
|
45
|
+
title: str = Field(min_length=1)
|
|
46
|
+
description: Optional[str] = None
|
|
47
|
+
created_by: Optional[str] = Field(alias="createdBy", default=None)
|
|
48
|
+
created_at: datetime = Field(alias="createdAt")
|
|
49
|
+
updated_at: Optional[datetime] = Field(alias="updatedAt", default=None)
|
|
50
|
+
org_id: Optional[str] = Field(alias="orgId", default=None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PaginatedProducts(BaseModel):
|
|
54
|
+
"""Paginated response for products list."""
|
|
55
|
+
|
|
56
|
+
data: list[Product]
|
|
57
|
+
limit: int
|
|
58
|
+
offset: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PaginatedProductVersions(BaseModel):
|
|
62
|
+
"""Paginated response for product versions list."""
|
|
63
|
+
|
|
64
|
+
data: list[ProductVersion]
|
|
65
|
+
limit: int
|
|
66
|
+
offset: int
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PropertyValue(BaseModel):
|
|
70
|
+
"""Base class for property values with typed access."""
|
|
71
|
+
|
|
72
|
+
raw_value: str = Field(alias="value")
|
|
73
|
+
parsed_value: Optional[Any] = Field(alias="parsedValue", default=None)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def value(self) -> Any:
|
|
77
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
78
|
+
return self.parsed_value if self.parsed_value is not None else self.raw_value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NumericProperty(BaseModel):
|
|
82
|
+
"""Numeric property representation.
|
|
83
|
+
|
|
84
|
+
Note: The `category` field contains normalized/canonicalized values.
|
|
85
|
+
Categories are normalized server-side (upper-cased, deduplicated) and
|
|
86
|
+
may differ from the original input values.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
id: str = Field(min_length=1)
|
|
90
|
+
product_id: Optional[str] = Field(alias="productId", default=None)
|
|
91
|
+
product_version_number: Optional[int] = Field(alias="productVersionNumber", default=None)
|
|
92
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
93
|
+
position: float
|
|
94
|
+
name: str = Field(min_length=1)
|
|
95
|
+
value: str
|
|
96
|
+
category: str
|
|
97
|
+
display_unit: Optional[str] = Field(alias="displayUnit", default=None)
|
|
98
|
+
owner: str = Field(min_length=1)
|
|
99
|
+
type: str = Field(min_length=1)
|
|
100
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
104
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
105
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TextProperty(BaseModel):
|
|
109
|
+
"""Text property representation."""
|
|
110
|
+
|
|
111
|
+
id: str = Field(min_length=1)
|
|
112
|
+
product_id: Optional[str] = Field(alias="productId", default=None)
|
|
113
|
+
product_version_number: Optional[int] = Field(alias="productVersionNumber", default=None)
|
|
114
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
115
|
+
position: float
|
|
116
|
+
name: str = Field(min_length=1)
|
|
117
|
+
value: str
|
|
118
|
+
owner: str = Field(min_length=1)
|
|
119
|
+
type: str = Field(min_length=1)
|
|
120
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
124
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
125
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class DateProperty(BaseModel):
|
|
129
|
+
"""Date property representation."""
|
|
130
|
+
|
|
131
|
+
id: str = Field(min_length=1)
|
|
132
|
+
product_id: Optional[str] = Field(alias="productId", default=None)
|
|
133
|
+
product_version_number: Optional[int] = Field(alias="productVersionNumber", default=None)
|
|
134
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
135
|
+
position: float
|
|
136
|
+
name: str = Field(min_length=1)
|
|
137
|
+
value: str
|
|
138
|
+
owner: str = Field(min_length=1)
|
|
139
|
+
type: str = Field(min_length=1)
|
|
140
|
+
parsed_value: Optional[str] = Field(alias="parsedValue", default=None)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def typed_value(self) -> str:
|
|
144
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
145
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PropertySearchResult(BaseModel):
|
|
149
|
+
"""Property search result with unified fields across all property types."""
|
|
150
|
+
|
|
151
|
+
id: str = Field(min_length=1)
|
|
152
|
+
workspace_id: str = Field(alias="workspaceId", min_length=1)
|
|
153
|
+
product_id: str = Field(alias="productId", min_length=1)
|
|
154
|
+
product_version_number: Optional[int] = Field(alias="productVersionNumber", default=None)
|
|
155
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
156
|
+
property_type: str = Field(alias="propertyType", min_length=1)
|
|
157
|
+
name: str = Field(min_length=1)
|
|
158
|
+
category: Optional[str] = None
|
|
159
|
+
display_unit: Optional[str] = Field(alias="displayUnit", default=None)
|
|
160
|
+
value: Any # Raw value from GraphQL
|
|
161
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
162
|
+
owner: str = Field(min_length=1)
|
|
163
|
+
created_by: str = Field(alias="createdBy", min_length=1)
|
|
164
|
+
created_at: str = Field(alias="createdAt", min_length=1)
|
|
165
|
+
updated_at: str = Field(alias="updatedAt", min_length=1)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
169
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
170
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PropertySearchResponse(BaseModel):
|
|
174
|
+
"""Response for property search queries."""
|
|
175
|
+
|
|
176
|
+
query: str
|
|
177
|
+
hits: List[PropertySearchResult]
|
|
178
|
+
total: int
|
|
179
|
+
limit: int
|
|
180
|
+
offset: int
|
|
181
|
+
processing_time_ms: int = Field(alias="processingTimeMs")
|
|
182
|
+
|
|
183
|
+
|