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/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
+
@@ -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
+