poelis-sdk 0.3.2__tar.gz → 0.3.4__tar.gz
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-0.3.2 → poelis_sdk-0.3.4}/PKG-INFO +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/pyproject.toml +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/__init__.py +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/_transport.py +11 -3
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/browser.py +62 -20
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/client.py +5 -4
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/items.py +1 -1
- poelis_sdk-0.3.4/src/poelis_sdk/models.py +130 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/products.py +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/search.py +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/workspaces.py +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/tests/test_integration_smoke.py +1 -0
- poelis_sdk-0.3.4/tests/test_typed_properties.py +212 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/uv.lock +1 -1
- poelis_sdk-0.3.2/src/poelis_sdk/models.py +0 -27
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/.github/workflows/ci.yml +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/.github/workflows/codeql.yml +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/.github/workflows/publish-on-push.yml +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/.gitignore +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/LICENSE +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/README.md +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/notebooks/try_poelis_sdk.ipynb +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/exceptions.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/logging.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/poelis_sdk/org_validation.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/tests/test_client_basic.py +1 -1
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/tests/test_errors_and_backoff.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/tests/test_items_client.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/tests/test_search_client.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/src/tests/test_transport_and_products.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/tests/__init__.py +0 -0
- {poelis_sdk-0.3.2 → poelis_sdk-0.3.4}/tests/test_browser_navigation.py +1 -1
|
@@ -7,7 +7,7 @@ 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,
|
|
10
|
+
from .logging import configure_logging, debug_logging, get_logger, quiet_logging, verbose_logging
|
|
11
11
|
|
|
12
12
|
__all__ = ["PoelisClient", "__version__", "configure_logging", "quiet_logging", "verbose_logging", "debug_logging", "get_logger"]
|
|
13
13
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, Mapping, Optional
|
|
4
3
|
import os
|
|
5
|
-
import time
|
|
6
4
|
import random
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Mapping, Optional
|
|
7
|
+
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
9
|
-
from .exceptions import
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
ClientError,
|
|
12
|
+
HTTPError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
UnauthorizedError,
|
|
17
|
+
)
|
|
10
18
|
|
|
11
19
|
"""HTTP transport abstraction for the Poelis SDK.
|
|
12
20
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, List, Optional
|
|
4
|
-
from types import MethodType
|
|
5
3
|
import re
|
|
6
4
|
import time
|
|
5
|
+
from types import MethodType
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
7
|
|
|
8
8
|
from .org_validation import get_organization_context_message
|
|
9
9
|
|
|
@@ -140,39 +140,77 @@ class _Node:
|
|
|
140
140
|
self._props_loaded_at = time.time()
|
|
141
141
|
return self._props_cache
|
|
142
142
|
# Try direct properties(itemId: ...) first; fallback to searchProperties
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
# Attempt 1: query with parsedValue support
|
|
144
|
+
q_parsed = (
|
|
145
|
+
"query($iid: ID!) \n"
|
|
145
146
|
" properties(itemId: $iid) {\n"
|
|
146
147
|
" __typename\n"
|
|
147
|
-
" ... on NumericProperty {
|
|
148
|
-
" ... on TextProperty { value }\n"
|
|
149
|
-
" ... on DateProperty { value }\n"
|
|
148
|
+
" ... on NumericProperty { id name category value parsedValue }\n"
|
|
149
|
+
" ... on TextProperty { id name value parsedValue }\n"
|
|
150
|
+
" ... on DateProperty { id name value }\n"
|
|
150
151
|
" }\n"
|
|
151
152
|
"}"
|
|
152
153
|
)
|
|
153
154
|
try:
|
|
154
|
-
r = self._client._transport.graphql(
|
|
155
|
+
r = self._client._transport.graphql(q_parsed, {"iid": self._id})
|
|
155
156
|
r.raise_for_status()
|
|
156
157
|
data = r.json()
|
|
157
158
|
if "errors" in data:
|
|
158
|
-
raise RuntimeError(data["errors"]) #
|
|
159
|
+
raise RuntimeError(data["errors"]) # try value-only shape
|
|
159
160
|
self._props_cache = data.get("data", {}).get("properties", []) or []
|
|
160
161
|
self._props_loaded_at = time.time()
|
|
161
162
|
except Exception:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"
|
|
165
|
-
"
|
|
163
|
+
# Attempt 2: value-only, legacy compatible
|
|
164
|
+
q_value_only = (
|
|
165
|
+
"query($iid: ID!) \n"
|
|
166
|
+
" properties(itemId: $iid) {\n"
|
|
167
|
+
" __typename\n"
|
|
168
|
+
" ... on NumericProperty { id name category value }\n"
|
|
169
|
+
" ... on TextProperty { id name value }\n"
|
|
170
|
+
" ... on DateProperty { id name value }\n"
|
|
166
171
|
" }\n"
|
|
167
172
|
"}"
|
|
168
173
|
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
try:
|
|
175
|
+
r = self._client._transport.graphql(q_value_only, {"iid": self._id})
|
|
176
|
+
r.raise_for_status()
|
|
177
|
+
data = r.json()
|
|
178
|
+
if "errors" in data:
|
|
179
|
+
raise RuntimeError(data["errors"]) # trigger fallback to search
|
|
180
|
+
self._props_cache = data.get("data", {}).get("properties", []) or []
|
|
181
|
+
self._props_loaded_at = time.time()
|
|
182
|
+
except Exception:
|
|
183
|
+
# Fallback to searchProperties
|
|
184
|
+
q2_parsed = (
|
|
185
|
+
"query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
186
|
+
" searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
|
|
187
|
+
" hits { id workspaceId productId itemId propertyType name category value parsedValue owner }\n"
|
|
188
|
+
" }\n"
|
|
189
|
+
"}"
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
r2 = self._client._transport.graphql(q2_parsed, {"iid": self._id, "limit": 100, "offset": 0})
|
|
193
|
+
r2.raise_for_status()
|
|
194
|
+
data2 = r2.json()
|
|
195
|
+
if "errors" in data2:
|
|
196
|
+
raise RuntimeError(data2["errors"]) # try minimal
|
|
197
|
+
self._props_cache = data2.get("data", {}).get("searchProperties", {}).get("hits", []) or []
|
|
198
|
+
self._props_loaded_at = time.time()
|
|
199
|
+
except Exception:
|
|
200
|
+
q2_min = (
|
|
201
|
+
"query($iid: ID!, $limit: Int!, $offset: Int!) {\n"
|
|
202
|
+
" searchProperties(q: \"*\", itemId: $iid, limit: $limit, offset: $offset) {\n"
|
|
203
|
+
" hits { id workspaceId productId itemId propertyType name category value owner }\n"
|
|
204
|
+
" }\n"
|
|
205
|
+
"}"
|
|
206
|
+
)
|
|
207
|
+
r3 = self._client._transport.graphql(q2_min, {"iid": self._id, "limit": 100, "offset": 0})
|
|
208
|
+
r3.raise_for_status()
|
|
209
|
+
data3 = r3.json()
|
|
210
|
+
if "errors" in data3:
|
|
211
|
+
raise RuntimeError(data3["errors"]) # propagate
|
|
212
|
+
self._props_cache = data3.get("data", {}).get("searchProperties", {}).get("hits", []) or []
|
|
213
|
+
self._props_loaded_at = time.time()
|
|
176
214
|
return self._props_cache
|
|
177
215
|
|
|
178
216
|
def _props_key_map(self) -> Dict[str, Dict[str, Any]]:
|
|
@@ -419,6 +457,10 @@ class _PropWrapper:
|
|
|
419
457
|
@property
|
|
420
458
|
def value(self) -> Any: # type: ignore[override]
|
|
421
459
|
p = self._raw
|
|
460
|
+
# Use parsedValue if available (new backend feature)
|
|
461
|
+
if "parsedValue" in p:
|
|
462
|
+
return p["parsedValue"]
|
|
463
|
+
# Fallback to legacy parsing logic for backward compatibility
|
|
422
464
|
# searchProperties shape
|
|
423
465
|
if "numericValue" in p and p.get("numericValue") is not None:
|
|
424
466
|
return p["numericValue"]
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Optional
|
|
4
3
|
import os
|
|
4
|
+
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from pydantic import BaseModel, Field, HttpUrl
|
|
7
|
+
|
|
7
8
|
from ._transport import Transport
|
|
8
|
-
from .
|
|
9
|
+
from .browser import Browser
|
|
9
10
|
from .items import ItemsClient
|
|
11
|
+
from .logging import quiet_logging
|
|
12
|
+
from .products import ProductsClient
|
|
10
13
|
from .search import SearchClient
|
|
11
14
|
from .workspaces import WorkspacesClient
|
|
12
|
-
from .browser import Browser
|
|
13
|
-
from .logging import quiet_logging
|
|
14
15
|
|
|
15
16
|
"""Core client for the Poelis Python SDK.
|
|
16
17
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
"""Pydantic models for SDK resources."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Product(BaseModel):
|
|
11
|
+
"""Product resource representation."""
|
|
12
|
+
|
|
13
|
+
id: str = Field(min_length=1)
|
|
14
|
+
name: str = Field(min_length=1)
|
|
15
|
+
workspaceId: Optional[str] = None
|
|
16
|
+
code: Optional[str] = None
|
|
17
|
+
description: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PaginatedProducts(BaseModel):
|
|
21
|
+
"""Paginated response for products list."""
|
|
22
|
+
|
|
23
|
+
data: list[Product]
|
|
24
|
+
limit: int
|
|
25
|
+
offset: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PropertyValue(BaseModel):
|
|
29
|
+
"""Base class for property values with typed access."""
|
|
30
|
+
|
|
31
|
+
raw_value: str = Field(alias="value")
|
|
32
|
+
parsed_value: Optional[Any] = Field(alias="parsedValue", default=None)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def value(self) -> Any:
|
|
36
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
37
|
+
return self.parsed_value if self.parsed_value is not None else self.raw_value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NumericProperty(BaseModel):
|
|
41
|
+
"""Numeric property representation."""
|
|
42
|
+
|
|
43
|
+
id: str = Field(min_length=1)
|
|
44
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
45
|
+
position: float
|
|
46
|
+
name: str = Field(min_length=1)
|
|
47
|
+
value: str
|
|
48
|
+
category: str
|
|
49
|
+
display_unit: Optional[str] = Field(alias="displayUnit", default=None)
|
|
50
|
+
owner: str = Field(min_length=1)
|
|
51
|
+
type: str = Field(min_length=1)
|
|
52
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
56
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
57
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TextProperty(BaseModel):
|
|
61
|
+
"""Text property representation."""
|
|
62
|
+
|
|
63
|
+
id: str = Field(min_length=1)
|
|
64
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
65
|
+
position: float
|
|
66
|
+
name: str = Field(min_length=1)
|
|
67
|
+
value: str
|
|
68
|
+
owner: str = Field(min_length=1)
|
|
69
|
+
type: str = Field(min_length=1)
|
|
70
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
74
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
75
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DateProperty(BaseModel):
|
|
79
|
+
"""Date property representation."""
|
|
80
|
+
|
|
81
|
+
id: str = Field(min_length=1)
|
|
82
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
83
|
+
position: float
|
|
84
|
+
name: str = Field(min_length=1)
|
|
85
|
+
value: str
|
|
86
|
+
owner: str = Field(min_length=1)
|
|
87
|
+
type: str = Field(min_length=1)
|
|
88
|
+
parsed_value: Optional[str] = Field(alias="parsedValue", default=None)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def typed_value(self) -> str:
|
|
92
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
93
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PropertySearchResult(BaseModel):
|
|
97
|
+
"""Property search result with unified fields across all property types."""
|
|
98
|
+
|
|
99
|
+
id: str = Field(min_length=1)
|
|
100
|
+
workspace_id: str = Field(alias="workspaceId", min_length=1)
|
|
101
|
+
product_id: str = Field(alias="productId", min_length=1)
|
|
102
|
+
item_id: str = Field(alias="itemId", min_length=1)
|
|
103
|
+
property_type: str = Field(alias="propertyType", min_length=1)
|
|
104
|
+
name: str = Field(min_length=1)
|
|
105
|
+
category: Optional[str] = None
|
|
106
|
+
display_unit: Optional[str] = Field(alias="displayUnit", default=None)
|
|
107
|
+
value: Any # Raw value from GraphQL
|
|
108
|
+
parsed_value: Optional[Union[int, float, List[Any], str]] = Field(alias="parsedValue", default=None)
|
|
109
|
+
owner: str = Field(min_length=1)
|
|
110
|
+
created_by: str = Field(alias="createdBy", min_length=1)
|
|
111
|
+
created_at: str = Field(alias="createdAt", min_length=1)
|
|
112
|
+
updated_at: str = Field(alias="updatedAt", min_length=1)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def typed_value(self) -> Union[int, float, List[Any], str]:
|
|
116
|
+
"""Get the properly typed value, falling back to raw string if parsing failed."""
|
|
117
|
+
return self.parsed_value if self.parsed_value is not None else self.value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PropertySearchResponse(BaseModel):
|
|
121
|
+
"""Response for property search queries."""
|
|
122
|
+
|
|
123
|
+
query: str
|
|
124
|
+
hits: List[PropertySearchResult]
|
|
125
|
+
total: int
|
|
126
|
+
limit: int
|
|
127
|
+
offset: int
|
|
128
|
+
processing_time_ms: int = Field(alias="processingTimeMs")
|
|
129
|
+
|
|
130
|
+
|
|
@@ -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 value owner }\n"
|
|
57
|
+
" hits { id workspaceId productId itemId propertyType name category value parsedValue owner }\n"
|
|
58
58
|
" }\n"
|
|
59
59
|
"}"
|
|
60
60
|
)
|
|
@@ -3,7 +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
|
|
6
|
+
from .org_validation import filter_by_organization, validate_workspace_organization
|
|
7
7
|
|
|
8
8
|
"""Workspaces GraphQL client."""
|
|
9
9
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Tests for typed property values functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from poelis_sdk.browser import _PropWrapper
|
|
8
|
+
from poelis_sdk.models import DateProperty, NumericProperty, PropertySearchResult, TextProperty
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from _pytest.capture import CaptureFixture # noqa: F401
|
|
12
|
+
from _pytest.fixtures import FixtureRequest # noqa: F401
|
|
13
|
+
from _pytest.logging import LogCaptureFixture # noqa: F401
|
|
14
|
+
from _pytest.monkeypatch import MonkeyPatch # noqa: F401
|
|
15
|
+
from pytest_mock.plugin import MockerFixture # noqa: F401
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestTypedPropertyModels:
|
|
19
|
+
"""Test Pydantic models for typed property values."""
|
|
20
|
+
|
|
21
|
+
def test_numeric_property_with_parsed_value(self) -> None:
|
|
22
|
+
"""Test NumericProperty model with parsedValue field."""
|
|
23
|
+
prop_data = {
|
|
24
|
+
"id": "prop1",
|
|
25
|
+
"itemId": "item1",
|
|
26
|
+
"position": 1.0,
|
|
27
|
+
"name": "Weight",
|
|
28
|
+
"value": "42.5",
|
|
29
|
+
"category": "Physical",
|
|
30
|
+
"displayUnit": "kg",
|
|
31
|
+
"owner": "user1",
|
|
32
|
+
"type": "numeric",
|
|
33
|
+
"parsedValue": 42.5
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
prop = NumericProperty(**prop_data)
|
|
37
|
+
assert prop.typed_value == 42.5
|
|
38
|
+
assert isinstance(prop.typed_value, float)
|
|
39
|
+
assert prop.value == "42.5" # Raw value remains string
|
|
40
|
+
|
|
41
|
+
def test_numeric_property_without_parsed_value(self) -> None:
|
|
42
|
+
"""Test NumericProperty model without parsedValue field (fallback)."""
|
|
43
|
+
prop_data = {
|
|
44
|
+
"id": "prop1",
|
|
45
|
+
"itemId": "item1",
|
|
46
|
+
"position": 1.0,
|
|
47
|
+
"name": "Weight",
|
|
48
|
+
"value": "42.5",
|
|
49
|
+
"category": "Physical",
|
|
50
|
+
"displayUnit": "kg",
|
|
51
|
+
"owner": "user1",
|
|
52
|
+
"type": "numeric"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
prop = NumericProperty(**prop_data)
|
|
56
|
+
assert prop.typed_value == "42.5" # Falls back to raw string
|
|
57
|
+
assert prop.value == "42.5"
|
|
58
|
+
|
|
59
|
+
def test_text_property_with_parsed_array(self) -> None:
|
|
60
|
+
"""Test TextProperty model with parsed array value."""
|
|
61
|
+
prop_data = {
|
|
62
|
+
"id": "prop2",
|
|
63
|
+
"itemId": "item1",
|
|
64
|
+
"position": 2.0,
|
|
65
|
+
"name": "Tags",
|
|
66
|
+
"value": '["tag1", "tag2", "tag3"]',
|
|
67
|
+
"owner": "user1",
|
|
68
|
+
"type": "text",
|
|
69
|
+
"parsedValue": ["tag1", "tag2", "tag3"]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
prop = TextProperty(**prop_data)
|
|
73
|
+
assert prop.typed_value == ["tag1", "tag2", "tag3"]
|
|
74
|
+
assert isinstance(prop.typed_value, list)
|
|
75
|
+
assert prop.value == '["tag1", "tag2", "tag3"]' # Raw value remains string
|
|
76
|
+
|
|
77
|
+
def test_text_property_with_parsed_integer(self) -> None:
|
|
78
|
+
"""Test TextProperty model with parsed integer value."""
|
|
79
|
+
prop_data = {
|
|
80
|
+
"id": "prop3",
|
|
81
|
+
"itemId": "item1",
|
|
82
|
+
"position": 3.0,
|
|
83
|
+
"name": "Count",
|
|
84
|
+
"value": "100",
|
|
85
|
+
"owner": "user1",
|
|
86
|
+
"type": "text",
|
|
87
|
+
"parsedValue": 100
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
prop = TextProperty(**prop_data)
|
|
91
|
+
assert prop.typed_value == 100
|
|
92
|
+
assert isinstance(prop.typed_value, int)
|
|
93
|
+
assert prop.value == "100" # Raw value remains string
|
|
94
|
+
|
|
95
|
+
def test_date_property_with_parsed_value(self) -> None:
|
|
96
|
+
"""Test DateProperty model with parsedValue field."""
|
|
97
|
+
prop_data = {
|
|
98
|
+
"id": "prop4",
|
|
99
|
+
"itemId": "item1",
|
|
100
|
+
"position": 4.0,
|
|
101
|
+
"name": "Created Date",
|
|
102
|
+
"value": "2024-01-15",
|
|
103
|
+
"owner": "user1",
|
|
104
|
+
"type": "date",
|
|
105
|
+
"parsedValue": "2024-01-15"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
prop = DateProperty(**prop_data)
|
|
109
|
+
assert prop.typed_value == "2024-01-15"
|
|
110
|
+
assert isinstance(prop.typed_value, str)
|
|
111
|
+
assert prop.value == "2024-01-15"
|
|
112
|
+
|
|
113
|
+
def test_property_search_result_with_parsed_value(self) -> None:
|
|
114
|
+
"""Test PropertySearchResult model with parsedValue field."""
|
|
115
|
+
result_data = {
|
|
116
|
+
"id": "prop5",
|
|
117
|
+
"workspaceId": "ws1",
|
|
118
|
+
"productId": "prod1",
|
|
119
|
+
"itemId": "item1",
|
|
120
|
+
"propertyType": "numeric",
|
|
121
|
+
"name": "Temperature",
|
|
122
|
+
"category": "Environmental",
|
|
123
|
+
"displayUnit": "°C",
|
|
124
|
+
"value": "25.3",
|
|
125
|
+
"parsedValue": 25.3,
|
|
126
|
+
"owner": "user1",
|
|
127
|
+
"createdBy": "user1",
|
|
128
|
+
"createdAt": "2024-01-15T10:00:00Z",
|
|
129
|
+
"updatedAt": "2024-01-15T10:00:00Z"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
result = PropertySearchResult(**result_data)
|
|
133
|
+
assert result.typed_value == 25.3
|
|
134
|
+
assert isinstance(result.typed_value, float)
|
|
135
|
+
assert result.value == "25.3" # Raw value remains string
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestPropWrapper:
|
|
139
|
+
"""Test _PropWrapper class with typed values."""
|
|
140
|
+
|
|
141
|
+
def test_prop_wrapper_with_parsed_value(self) -> None:
|
|
142
|
+
"""Test _PropWrapper with parsedValue field."""
|
|
143
|
+
prop_data = {
|
|
144
|
+
"id": "prop1",
|
|
145
|
+
"name": "Weight",
|
|
146
|
+
"value": "42.5",
|
|
147
|
+
"parsedValue": 42.5,
|
|
148
|
+
"category": "Physical"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
wrapper = _PropWrapper(prop_data)
|
|
152
|
+
assert wrapper.value == 42.5
|
|
153
|
+
assert isinstance(wrapper.value, float)
|
|
154
|
+
assert wrapper.category == "Physical"
|
|
155
|
+
|
|
156
|
+
def test_prop_wrapper_without_parsed_value_fallback(self) -> None:
|
|
157
|
+
"""Test _PropWrapper fallback when parsedValue is not available."""
|
|
158
|
+
prop_data = {
|
|
159
|
+
"id": "prop1",
|
|
160
|
+
"name": "Weight",
|
|
161
|
+
"value": "42.5",
|
|
162
|
+
"category": "Physical"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
wrapper = _PropWrapper(prop_data)
|
|
166
|
+
assert wrapper.value == "42.5" # Falls back to raw value
|
|
167
|
+
assert isinstance(wrapper.value, str)
|
|
168
|
+
assert wrapper.category == "Physical"
|
|
169
|
+
|
|
170
|
+
def test_prop_wrapper_with_parsed_array(self) -> None:
|
|
171
|
+
"""Test _PropWrapper with parsed array value."""
|
|
172
|
+
prop_data = {
|
|
173
|
+
"id": "prop2",
|
|
174
|
+
"name": "Tags",
|
|
175
|
+
"value": '["tag1", "tag2"]',
|
|
176
|
+
"parsedValue": ["tag1", "tag2"],
|
|
177
|
+
"category": "Metadata"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
wrapper = _PropWrapper(prop_data)
|
|
181
|
+
assert wrapper.value == ["tag1", "tag2"]
|
|
182
|
+
assert isinstance(wrapper.value, list)
|
|
183
|
+
assert wrapper.category == "Metadata"
|
|
184
|
+
|
|
185
|
+
def test_prop_wrapper_legacy_numeric_parsing(self) -> None:
|
|
186
|
+
"""Test _PropWrapper with legacy numeric parsing (integerPart/exponent)."""
|
|
187
|
+
prop_data = {
|
|
188
|
+
"id": "prop3",
|
|
189
|
+
"name": "Legacy Numeric",
|
|
190
|
+
"integerPart": 42,
|
|
191
|
+
"exponent": 1,
|
|
192
|
+
"category": "Legacy"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
wrapper = _PropWrapper(prop_data)
|
|
196
|
+
assert wrapper.value == 420 # 42 * 10^1
|
|
197
|
+
assert isinstance(wrapper.value, int)
|
|
198
|
+
assert wrapper.category == "Legacy"
|
|
199
|
+
|
|
200
|
+
def test_prop_wrapper_legacy_search_properties(self) -> None:
|
|
201
|
+
"""Test _PropWrapper with legacy searchProperties format."""
|
|
202
|
+
prop_data = {
|
|
203
|
+
"id": "prop4",
|
|
204
|
+
"name": "Search Numeric",
|
|
205
|
+
"numericValue": 123.45,
|
|
206
|
+
"category": "Search"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
wrapper = _PropWrapper(prop_data)
|
|
210
|
+
assert wrapper.value == 123.45
|
|
211
|
+
assert isinstance(wrapper.value, float)
|
|
212
|
+
assert wrapper.category == "Search"
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Optional
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel, Field
|
|
6
|
-
|
|
7
|
-
"""Pydantic models for SDK resources."""
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class Product(BaseModel):
|
|
11
|
-
"""Product resource representation."""
|
|
12
|
-
|
|
13
|
-
id: str = Field(min_length=1)
|
|
14
|
-
name: str = Field(min_length=1)
|
|
15
|
-
workspaceId: Optional[str] = None
|
|
16
|
-
code: Optional[str] = None
|
|
17
|
-
description: Optional[str] = None
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class PaginatedProducts(BaseModel):
|
|
21
|
-
"""Paginated response for products list."""
|
|
22
|
-
|
|
23
|
-
data: list[Product]
|
|
24
|
-
limit: int
|
|
25
|
-
offset: int
|
|
26
|
-
|
|
27
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from typing import TYPE_CHECKING
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
from poelis_sdk import PoelisClient
|
|
13
12
|
|
|
14
13
|
if TYPE_CHECKING:
|
|
@@ -37,6 +36,7 @@ def test_client_api_key_headers(monkeypatch: "MonkeyPatch") -> None:
|
|
|
37
36
|
"""When api_key and org_id are provided, use API key and X-Poelis-Org headers by default."""
|
|
38
37
|
|
|
39
38
|
import httpx
|
|
39
|
+
|
|
40
40
|
from poelis_sdk.client import Transport as _T
|
|
41
41
|
|
|
42
42
|
class _Tpt(httpx.BaseTransport):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|