dpp-sdk 0.1.0__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.
- dpp_sdk/__init__.py +76 -0
- dpp_sdk/clients/__init__.py +54 -0
- dpp_sdk/clients/_http.py +143 -0
- dpp_sdk/clients/errors.py +49 -0
- dpp_sdk/clients/payloads.py +118 -0
- dpp_sdk/clients/registry.py +25 -0
- dpp_sdk/clients/repo.py +130 -0
- dpp_sdk/core/__init__.py +38 -0
- dpp_sdk/core/errors.py +21 -0
- dpp_sdk/core/model.py +205 -0
- dpp_sdk/core/validation.py +200 -0
- dpp_sdk/dpp4fun/__init__.py +33 -0
- dpp_sdk/dpp4fun/model.py +147 -0
- dpp_sdk/dpp4fun/transport.py +99 -0
- dpp_sdk/dpp4fun/validation.py +200 -0
- dpp_sdk/py.typed +0 -0
- dpp_sdk-0.1.0.dist-info/METADATA +99 -0
- dpp_sdk-0.1.0.dist-info/RECORD +20 -0
- dpp_sdk-0.1.0.dist-info/WHEEL +4 -0
- dpp_sdk-0.1.0.dist-info/licenses/LICENSE +183 -0
dpp_sdk/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Python SDK for Digital Product Passports (DPP).
|
|
2
|
+
|
|
3
|
+
Idiomatic Pydantic v2 port of the Java DPP SDK (see ../dpp-sdk-platform).
|
|
4
|
+
|
|
5
|
+
Sub-packages:
|
|
6
|
+
dpp_sdk.core -- reusable core DPP model, validation, identifiers
|
|
7
|
+
dpp_sdk.dpp4fun -- furniture-specific DPP aggregate, validation, JSON transport
|
|
8
|
+
dpp_sdk.clients -- HTTP clients for the DPP repository and registry APIs
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .core import (
|
|
12
|
+
Address,
|
|
13
|
+
Contact,
|
|
14
|
+
Documentation,
|
|
15
|
+
Dpp,
|
|
16
|
+
DppCore,
|
|
17
|
+
DppError,
|
|
18
|
+
DppMappingError,
|
|
19
|
+
DppValidationError,
|
|
20
|
+
Email,
|
|
21
|
+
Nameplate,
|
|
22
|
+
Organization,
|
|
23
|
+
OrganizationRole,
|
|
24
|
+
PassportMetadata,
|
|
25
|
+
Telephone,
|
|
26
|
+
validate_dpp_core,
|
|
27
|
+
)
|
|
28
|
+
from .dpp4fun import (
|
|
29
|
+
BillOfMaterials,
|
|
30
|
+
Characteristics,
|
|
31
|
+
Component,
|
|
32
|
+
Dimensions,
|
|
33
|
+
Dpp4Fun,
|
|
34
|
+
Dpp4FunJsonCodec,
|
|
35
|
+
Material,
|
|
36
|
+
Part,
|
|
37
|
+
ProductClassification,
|
|
38
|
+
from_json,
|
|
39
|
+
from_json_and_validate,
|
|
40
|
+
to_json,
|
|
41
|
+
validate_dpp4fun,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Address",
|
|
48
|
+
"BillOfMaterials",
|
|
49
|
+
"Characteristics",
|
|
50
|
+
"Component",
|
|
51
|
+
"Contact",
|
|
52
|
+
"Dimensions",
|
|
53
|
+
"Documentation",
|
|
54
|
+
"Dpp",
|
|
55
|
+
"Dpp4Fun",
|
|
56
|
+
"Dpp4FunJsonCodec",
|
|
57
|
+
"DppCore",
|
|
58
|
+
"DppError",
|
|
59
|
+
"DppMappingError",
|
|
60
|
+
"DppValidationError",
|
|
61
|
+
"Email",
|
|
62
|
+
"Material",
|
|
63
|
+
"Nameplate",
|
|
64
|
+
"Organization",
|
|
65
|
+
"OrganizationRole",
|
|
66
|
+
"PassportMetadata",
|
|
67
|
+
"Part",
|
|
68
|
+
"ProductClassification",
|
|
69
|
+
"Telephone",
|
|
70
|
+
"__version__",
|
|
71
|
+
"from_json",
|
|
72
|
+
"from_json_and_validate",
|
|
73
|
+
"to_json",
|
|
74
|
+
"validate_dpp4fun",
|
|
75
|
+
"validate_dpp_core",
|
|
76
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""HTTP clients for the DPP repository and registry APIs (prEN-18222-aligned).
|
|
2
|
+
|
|
3
|
+
Port of `dpp.repo.client.*` / `dpp.registry.client.*` from
|
|
4
|
+
../dpp-sdk-platform/dpp-sdk-clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._http import DppCodec, DppValidator
|
|
8
|
+
from .errors import (
|
|
9
|
+
DppApiClientError,
|
|
10
|
+
DppClientError,
|
|
11
|
+
DppHttpClientError,
|
|
12
|
+
DppMappingClientError,
|
|
13
|
+
DppNetworkClientError,
|
|
14
|
+
DppValidationClientError,
|
|
15
|
+
)
|
|
16
|
+
from .payloads import (
|
|
17
|
+
CreateDppResponse,
|
|
18
|
+
DeleteDppResponse,
|
|
19
|
+
DppApiMessage,
|
|
20
|
+
DppApiResponse,
|
|
21
|
+
DppStatusCode,
|
|
22
|
+
MessageType,
|
|
23
|
+
ReadDppIdsRequest,
|
|
24
|
+
ReadDppIdsResponse,
|
|
25
|
+
RegisterDppRequest,
|
|
26
|
+
RegisterDppResponse,
|
|
27
|
+
UpdateDataElementRequest,
|
|
28
|
+
)
|
|
29
|
+
from .registry import DppRegistryClient
|
|
30
|
+
from .repo import DppRepoClient
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CreateDppResponse",
|
|
34
|
+
"DeleteDppResponse",
|
|
35
|
+
"DppApiClientError",
|
|
36
|
+
"DppApiMessage",
|
|
37
|
+
"DppApiResponse",
|
|
38
|
+
"DppClientError",
|
|
39
|
+
"DppCodec",
|
|
40
|
+
"DppHttpClientError",
|
|
41
|
+
"DppMappingClientError",
|
|
42
|
+
"DppNetworkClientError",
|
|
43
|
+
"DppRegistryClient",
|
|
44
|
+
"DppRepoClient",
|
|
45
|
+
"DppStatusCode",
|
|
46
|
+
"DppValidationClientError",
|
|
47
|
+
"DppValidator",
|
|
48
|
+
"MessageType",
|
|
49
|
+
"ReadDppIdsRequest",
|
|
50
|
+
"ReadDppIdsResponse",
|
|
51
|
+
"RegisterDppRequest",
|
|
52
|
+
"RegisterDppResponse",
|
|
53
|
+
"UpdateDataElementRequest",
|
|
54
|
+
]
|
dpp_sdk/clients/_http.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared HTTP transport and JSON helpers (port of ``HttpSupport``).
|
|
2
|
+
|
|
3
|
+
Provides base-URL handling, URL encoding, the request/parse/validate pipeline for
|
|
4
|
+
the API wrapper envelope, and the ``DppCodec`` / ``DppValidator`` typing contracts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Protocol, TypeVar
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
DppApiClientError,
|
|
17
|
+
DppHttpClientError,
|
|
18
|
+
DppMappingClientError,
|
|
19
|
+
DppNetworkClientError,
|
|
20
|
+
)
|
|
21
|
+
from .payloads import DppApiResponse
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
T_contra = TypeVar("T_contra", contravariant=True)
|
|
25
|
+
|
|
26
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
27
|
+
DEFAULT_REQUEST_TIMEOUT = 15.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DppCodec(Protocol[T]):
|
|
31
|
+
"""Converts a caller-owned DPP type to and from full JSON payloads."""
|
|
32
|
+
|
|
33
|
+
def to_json(self, dpp: T) -> str: ...
|
|
34
|
+
|
|
35
|
+
def from_json(self, raw: str) -> T: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DppValidator(Protocol[T_contra]):
|
|
39
|
+
"""Validates a caller-owned DPP type before a create request is sent."""
|
|
40
|
+
|
|
41
|
+
def __call__(self, dpp: T_contra) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_client() -> httpx.Client:
|
|
45
|
+
return httpx.Client(
|
|
46
|
+
timeout=httpx.Timeout(DEFAULT_REQUEST_TIMEOUT, connect=DEFAULT_CONNECT_TIMEOUT)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_base_url(base_url: str) -> str:
|
|
51
|
+
trimmed = base_url.strip()
|
|
52
|
+
if not trimmed:
|
|
53
|
+
raise ValueError("baseUrl must not be blank")
|
|
54
|
+
return trimmed.rstrip("/")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve(base_url: str, path_and_query: str) -> str:
|
|
58
|
+
normalized = path_and_query if path_and_query else "/"
|
|
59
|
+
if not normalized.startswith("/"):
|
|
60
|
+
normalized = "/" + normalized
|
|
61
|
+
return base_url + normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode_segment(value: str) -> str:
|
|
65
|
+
return quote(value, safe="")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def request_api(
|
|
69
|
+
client: httpx.Client,
|
|
70
|
+
method: str,
|
|
71
|
+
url: str,
|
|
72
|
+
body: str | None,
|
|
73
|
+
) -> DppApiResponse:
|
|
74
|
+
"""Run the full request pipeline and return a validated success envelope."""
|
|
75
|
+
headers = {"Accept": "application/json"}
|
|
76
|
+
if body is not None:
|
|
77
|
+
headers["Content-Type"] = "application/json"
|
|
78
|
+
try:
|
|
79
|
+
response = client.request(method, url, content=body, headers=headers)
|
|
80
|
+
except httpx.TimeoutException as exc:
|
|
81
|
+
raise DppNetworkClientError("DPP HTTP request timed out") from exc
|
|
82
|
+
except httpx.TransportError as exc:
|
|
83
|
+
raise DppNetworkClientError("DPP HTTP request could not complete") from exc
|
|
84
|
+
|
|
85
|
+
_require_http_success(response)
|
|
86
|
+
api_response = _parse_api_response(response.text)
|
|
87
|
+
_require_api_success(api_response, response.text)
|
|
88
|
+
return api_response
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _require_http_success(response: httpx.Response) -> None:
|
|
92
|
+
if not 200 <= response.status_code < 300:
|
|
93
|
+
raise DppHttpClientError(
|
|
94
|
+
f"DPP endpoint returned non-success HTTP status {response.status_code}",
|
|
95
|
+
response.status_code,
|
|
96
|
+
response.text,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_api_response(raw_body: str) -> DppApiResponse:
|
|
101
|
+
try:
|
|
102
|
+
return DppApiResponse.model_validate_json(raw_body)
|
|
103
|
+
except ValidationError as exc:
|
|
104
|
+
raise DppMappingClientError("DPP API response could not be mapped") from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _require_api_success(response: DppApiResponse, raw_body: str) -> None:
|
|
108
|
+
status_code = response.statusCode
|
|
109
|
+
if status_code is None:
|
|
110
|
+
raise DppMappingClientError("DPP API response could not be mapped: missing statusCode")
|
|
111
|
+
if not status_code.is_success:
|
|
112
|
+
raise DppApiClientError(
|
|
113
|
+
f"DPP API returned error status {status_code.value}",
|
|
114
|
+
status_code,
|
|
115
|
+
response.messages,
|
|
116
|
+
raw_body,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def require_payload(response: DppApiResponse) -> Any:
|
|
121
|
+
payload = response.payload
|
|
122
|
+
if payload is None:
|
|
123
|
+
raise _missing_field("payload")
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def require_text_field(payload: Any, field_name: str, field_path: str) -> str:
|
|
128
|
+
if not isinstance(payload, dict):
|
|
129
|
+
raise _missing_field(field_path)
|
|
130
|
+
value = payload.get(field_name)
|
|
131
|
+
if not isinstance(value, str) or not value.strip():
|
|
132
|
+
raise _missing_field(field_path)
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def require_field(payload: Any, field_name: str, field_path: str) -> Any:
|
|
137
|
+
if not isinstance(payload, dict) or payload.get(field_name) is None:
|
|
138
|
+
raise _missing_field(field_path)
|
|
139
|
+
return payload[field_name]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _missing_field(field_path: str) -> DppMappingClientError:
|
|
143
|
+
return DppMappingClientError(f"Missing required response field: {field_path}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Client exception hierarchy (port of ``dpp.*.client.exception``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .payloads import DppApiMessage, DppStatusCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DppClientError(Exception):
|
|
12
|
+
"""Base class for all DPP HTTP client errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DppValidationClientError(DppClientError):
|
|
16
|
+
"""Raised when a DPP fails validation before a request is sent."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DppMappingClientError(DppClientError):
|
|
20
|
+
"""Raised when request/response JSON cannot be serialized or parsed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DppNetworkClientError(DppClientError):
|
|
24
|
+
"""Raised when the HTTP request cannot complete (connection/timeout)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DppHttpClientError(DppClientError):
|
|
28
|
+
"""Raised when an endpoint returns a non-2xx HTTP status."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, status_code: int, response_body: str) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.response_body = response_body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DppApiClientError(DppClientError):
|
|
37
|
+
"""Raised when a 2xx response carries an error status in the API wrapper."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
message: str,
|
|
42
|
+
status_code: DppStatusCode | None,
|
|
43
|
+
messages: list[DppApiMessage] | None,
|
|
44
|
+
raw_response_body: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self.messages = messages
|
|
49
|
+
self.raw_response_body = raw_response_body
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""API wrapper envelope and request/response DTOs for the DPP HTTP clients.
|
|
2
|
+
|
|
3
|
+
Ports ``dpp.repo.payloads.*`` and ``dpp.registry.payloads.*`` (which are
|
|
4
|
+
identical for the shared envelope) into a single module. These models use
|
|
5
|
+
``extra='ignore'`` to mirror the clients' Jackson config, which disables
|
|
6
|
+
``FAIL_ON_UNKNOWN_PROPERTIES``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DppStatusCode(StrEnum):
|
|
19
|
+
"""Application-level status in the API wrapper ``statusCode`` field."""
|
|
20
|
+
|
|
21
|
+
Success = "Success"
|
|
22
|
+
SuccessCreated = "SuccessCreated"
|
|
23
|
+
SuccessAccepted = "SuccessAccepted"
|
|
24
|
+
SuccessNoContent = "SuccessNoContent"
|
|
25
|
+
ClientErrorBadRequest = "ClientErrorBadRequest"
|
|
26
|
+
ClientNotAuthorized = "ClientNotAuthorized"
|
|
27
|
+
ClientForbidden = "ClientForbidden"
|
|
28
|
+
ClientMethodNotAllowed = "ClientMethodNotAllowed"
|
|
29
|
+
ClientErrorResourceNotFound = "ClientErrorResourceNotFound"
|
|
30
|
+
ClientResourceConflict = "ClientResourceConflict"
|
|
31
|
+
ServerInternalError = "ServerInternalError"
|
|
32
|
+
ServerErrorBadGateway = "ServerErrorBadGateway"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def _missing_(cls, value: object) -> DppStatusCode | None:
|
|
36
|
+
# Mirror the Java @JsonCreator aliases.
|
|
37
|
+
aliases = {
|
|
38
|
+
"ClientErrorNotAuthorized": cls.ClientNotAuthorized,
|
|
39
|
+
"ClientErrorForbidden": cls.ClientForbidden,
|
|
40
|
+
}
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return aliases.get(value)
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_success(self) -> bool:
|
|
47
|
+
return self in {
|
|
48
|
+
DppStatusCode.Success,
|
|
49
|
+
DppStatusCode.SuccessCreated,
|
|
50
|
+
DppStatusCode.SuccessAccepted,
|
|
51
|
+
DppStatusCode.SuccessNoContent,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MessageType(StrEnum):
|
|
56
|
+
"""Severity/category for API wrapper messages."""
|
|
57
|
+
|
|
58
|
+
Info = "Info"
|
|
59
|
+
Warning = "Warning"
|
|
60
|
+
Error = "Error"
|
|
61
|
+
Exception = "Exception"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ClientBase(BaseModel):
|
|
65
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DppApiMessage(_ClientBase):
|
|
69
|
+
messageType: MessageType | None = None
|
|
70
|
+
text: str | None = None
|
|
71
|
+
code: str | None = None
|
|
72
|
+
correlationId: str | None = None
|
|
73
|
+
timestamp: datetime | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DppApiResponse(_ClientBase):
|
|
77
|
+
"""Standard response envelope; ``payload`` is left as raw JSON (``Any``)."""
|
|
78
|
+
|
|
79
|
+
statusCode: DppStatusCode | None = None
|
|
80
|
+
payload: Any = None
|
|
81
|
+
messages: list[DppApiMessage] | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- repository DTOs ------------------------------------------------------------
|
|
85
|
+
class CreateDppResponse(_ClientBase):
|
|
86
|
+
dppId: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DeleteDppResponse(_ClientBase):
|
|
90
|
+
statusCode: DppStatusCode | None = None
|
|
91
|
+
messages: list[DppApiMessage] | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ReadDppIdsRequest(_ClientBase):
|
|
95
|
+
productIdentifiers: list[str] | None = None
|
|
96
|
+
limit: int | None = None
|
|
97
|
+
cursor: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ReadDppIdsResponse(_ClientBase):
|
|
101
|
+
dppIdentifiers: list[str] | None = None
|
|
102
|
+
nextCursor: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class UpdateDataElementRequest(_ClientBase):
|
|
106
|
+
payload: Any = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- registry DTOs --------------------------------------------------------------
|
|
110
|
+
class RegisterDppRequest(_ClientBase):
|
|
111
|
+
productIdentifier: str | None = None
|
|
112
|
+
dppIdentifier: str | None = None
|
|
113
|
+
operatorIdentifier: str | None = None
|
|
114
|
+
repoUrl: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RegisterDppResponse(_ClientBase):
|
|
118
|
+
registryIdentifier: str | None = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""HTTP client for the DPP registry API (port of ``HttpDppRegistryClient``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from . import _http
|
|
8
|
+
from .payloads import RegisterDppRequest, RegisterDppResponse
|
|
9
|
+
|
|
10
|
+
_REGISTER_PATH = "/registerDPP"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DppRegistryClient:
|
|
14
|
+
def __init__(self, base_url: str, *, client: httpx.Client | None = None) -> None:
|
|
15
|
+
self._base_url = _http.normalize_base_url(base_url)
|
|
16
|
+
self._client = client if client is not None else _http.build_client()
|
|
17
|
+
|
|
18
|
+
def post_new_dpp_to_registry(self, request: RegisterDppRequest) -> RegisterDppResponse:
|
|
19
|
+
body = request.model_dump_json()
|
|
20
|
+
response = _http.request_api(
|
|
21
|
+
self._client, "POST", _http.resolve(self._base_url, _REGISTER_PATH), body
|
|
22
|
+
)
|
|
23
|
+
payload = _http.require_payload(response)
|
|
24
|
+
_http.require_text_field(payload, "registryIdentifier", "payload.registryIdentifier")
|
|
25
|
+
return RegisterDppResponse.model_validate(payload)
|
dpp_sdk/clients/repo.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""HTTP client for the DPP repository API (port of ``HttpDppRepoClient``).
|
|
2
|
+
|
|
3
|
+
Generic over a caller-owned DPP model type ``T``, mediated by a :class:`DppCodec`
|
|
4
|
+
(full-payload JSON) and a validator callable. Endpoint paths and the request
|
|
5
|
+
pipeline match the Java client exactly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Generic, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import _http
|
|
17
|
+
from .errors import DppMappingClientError, DppValidationClientError
|
|
18
|
+
from .payloads import CreateDppResponse, DeleteDppResponse, ReadDppIdsRequest, ReadDppIdsResponse
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
_DPPS_PATH = "/dpps"
|
|
23
|
+
_DPPS_BY_PRODUCT_ID_PATH = "/dppsByProductId/"
|
|
24
|
+
_DPPS_BY_PRODUCT_ID_AND_DATE_PATH = "/dppsByProductIdAndDate/"
|
|
25
|
+
_DPPS_BY_PRODUCT_IDS_PATH = "/dppsByProductIds"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DppRepoClient(Generic[T]):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
base_url: str,
|
|
32
|
+
codec: _http.DppCodec[T],
|
|
33
|
+
validator: _http.DppValidator[T],
|
|
34
|
+
*,
|
|
35
|
+
client: httpx.Client | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._base_url = _http.normalize_base_url(base_url)
|
|
38
|
+
self._codec = codec
|
|
39
|
+
self._validator = validator
|
|
40
|
+
self._client = client if client is not None else _http.build_client()
|
|
41
|
+
|
|
42
|
+
# --- lifecycle --------------------------------------------------------------
|
|
43
|
+
def create_dpp(self, dpp: T) -> CreateDppResponse:
|
|
44
|
+
try:
|
|
45
|
+
self._validator(dpp)
|
|
46
|
+
except Exception as exc: # noqa: BLE001 - re-raised as a client error
|
|
47
|
+
raise DppValidationClientError("DPP validation failed before request") from exc
|
|
48
|
+
body = self._serialize(dpp)
|
|
49
|
+
response = self._send("POST", _DPPS_PATH, body)
|
|
50
|
+
payload = _http.require_payload(response)
|
|
51
|
+
_http.require_text_field(payload, "dppId", "payload.dppId")
|
|
52
|
+
return CreateDppResponse.model_validate(payload)
|
|
53
|
+
|
|
54
|
+
def read_dpp_by_id(self, dpp_id: str) -> T:
|
|
55
|
+
response = self._send("GET", self._dpp_path(dpp_id), None)
|
|
56
|
+
return self._decode_dpp_payload(response)
|
|
57
|
+
|
|
58
|
+
def read_dpp_by_product_id(self, product_id: str) -> T:
|
|
59
|
+
path = _DPPS_BY_PRODUCT_ID_PATH + _http.encode_segment(product_id)
|
|
60
|
+
response = self._send("GET", path, None)
|
|
61
|
+
return self._decode_dpp_payload(response)
|
|
62
|
+
|
|
63
|
+
def read_dpp_version_by_product_id_and_date(self, product_id: str, date: datetime) -> T:
|
|
64
|
+
path = (
|
|
65
|
+
_DPPS_BY_PRODUCT_ID_AND_DATE_PATH
|
|
66
|
+
+ _http.encode_segment(product_id)
|
|
67
|
+
+ "?date="
|
|
68
|
+
+ _http.encode_segment(date.isoformat())
|
|
69
|
+
)
|
|
70
|
+
response = self._send("GET", path, None)
|
|
71
|
+
return self._decode_dpp_payload(response)
|
|
72
|
+
|
|
73
|
+
def read_dpp_ids_by_product_ids(
|
|
74
|
+
self, product_ids: list[str], limit: int | None = None, cursor: str | None = None
|
|
75
|
+
) -> ReadDppIdsResponse:
|
|
76
|
+
request_body = ReadDppIdsRequest(
|
|
77
|
+
productIdentifiers=product_ids, limit=limit, cursor=cursor
|
|
78
|
+
)
|
|
79
|
+
response = self._send("POST", _DPPS_BY_PRODUCT_IDS_PATH, request_body.model_dump_json())
|
|
80
|
+
payload = _http.require_payload(response)
|
|
81
|
+
_http.require_field(payload, "dppIdentifiers", "payload.dppIdentifiers")
|
|
82
|
+
return ReadDppIdsResponse.model_validate(payload)
|
|
83
|
+
|
|
84
|
+
def update_dpp_by_id(self, dpp_id: str, partial_dpp: Any) -> T:
|
|
85
|
+
body = json.dumps(partial_dpp)
|
|
86
|
+
response = self._send("PATCH", self._dpp_path(dpp_id), body)
|
|
87
|
+
return self._decode_dpp_payload(response)
|
|
88
|
+
|
|
89
|
+
def delete_dpp_by_id(self, dpp_id: str) -> DeleteDppResponse:
|
|
90
|
+
response = self._send("DELETE", self._dpp_path(dpp_id), None)
|
|
91
|
+
return DeleteDppResponse(statusCode=response.statusCode, messages=response.messages)
|
|
92
|
+
|
|
93
|
+
# --- fine-granular elements -------------------------------------------------
|
|
94
|
+
def read_data_element(self, dpp_id: str, element_path: str) -> Any:
|
|
95
|
+
response = self._send("GET", self._data_element_path(dpp_id, element_path), None)
|
|
96
|
+
return _http.require_payload(response)
|
|
97
|
+
|
|
98
|
+
def update_data_element(self, dpp_id: str, element_path: str, payload: Any) -> Any:
|
|
99
|
+
body = json.dumps({"payload": payload})
|
|
100
|
+
response = self._send("PATCH", self._data_element_path(dpp_id, element_path), body)
|
|
101
|
+
return _http.require_payload(response)
|
|
102
|
+
|
|
103
|
+
# --- internals --------------------------------------------------------------
|
|
104
|
+
def _send(self, method: str, path: str, body: str | None) -> Any:
|
|
105
|
+
return _http.request_api(self._client, method, _http.resolve(self._base_url, path), body)
|
|
106
|
+
|
|
107
|
+
def _serialize(self, dpp: T) -> str:
|
|
108
|
+
try:
|
|
109
|
+
json_str = self._codec.to_json(dpp)
|
|
110
|
+
except Exception as exc: # noqa: BLE001 - re-raised as a client error
|
|
111
|
+
raise DppMappingClientError("DPP serialization failed before request") from exc
|
|
112
|
+
if json_str is None:
|
|
113
|
+
raise DppMappingClientError("codec returned null JSON")
|
|
114
|
+
return json_str
|
|
115
|
+
|
|
116
|
+
def _decode_dpp_payload(self, response: Any) -> T:
|
|
117
|
+
payload = _http.require_payload(response)
|
|
118
|
+
payload_json = json.dumps(payload)
|
|
119
|
+
try:
|
|
120
|
+
return self._codec.from_json(payload_json)
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - re-raised as a client error
|
|
122
|
+
raise DppMappingClientError("DPP deserialization failed after response") from exc
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _dpp_path(dpp_id: str) -> str:
|
|
126
|
+
return _DPPS_PATH + "/" + _http.encode_segment(dpp_id)
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def _data_element_path(cls, dpp_id: str, element_path: str) -> str:
|
|
130
|
+
return cls._dpp_path(dpp_id) + "/elements/" + _http.encode_segment(element_path)
|
dpp_sdk/core/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Reusable core DPP model, validation, and identifiers.
|
|
2
|
+
|
|
3
|
+
Port of `dppsdk.core.*` from ../dpp-sdk-platform/dpp-datamodel/dpp-core.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .errors import DppError, DppMappingError, DppValidationError
|
|
7
|
+
from .model import (
|
|
8
|
+
Address,
|
|
9
|
+
Contact,
|
|
10
|
+
Documentation,
|
|
11
|
+
Dpp,
|
|
12
|
+
DppCore,
|
|
13
|
+
Email,
|
|
14
|
+
Nameplate,
|
|
15
|
+
Organization,
|
|
16
|
+
OrganizationRole,
|
|
17
|
+
PassportMetadata,
|
|
18
|
+
Telephone,
|
|
19
|
+
)
|
|
20
|
+
from .validation import validate_dpp_core
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Address",
|
|
24
|
+
"Contact",
|
|
25
|
+
"Documentation",
|
|
26
|
+
"Dpp",
|
|
27
|
+
"DppCore",
|
|
28
|
+
"DppError",
|
|
29
|
+
"DppMappingError",
|
|
30
|
+
"DppValidationError",
|
|
31
|
+
"Email",
|
|
32
|
+
"Nameplate",
|
|
33
|
+
"Organization",
|
|
34
|
+
"OrganizationRole",
|
|
35
|
+
"PassportMetadata",
|
|
36
|
+
"Telephone",
|
|
37
|
+
"validate_dpp_core",
|
|
38
|
+
]
|
dpp_sdk/core/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exception hierarchy for the DPP datamodel.
|
|
2
|
+
|
|
3
|
+
Mirrors the Java SDK's split between mapping/transport failures and semantic
|
|
4
|
+
validation failures. Construction-time (structural) failures surface as
|
|
5
|
+
``pydantic.ValidationError``; the semantic validators in
|
|
6
|
+
:mod:`dpp_sdk.core.validation` raise :class:`DppValidationError`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DppError(Exception):
|
|
13
|
+
"""Base class for all datamodel errors raised by the SDK."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DppValidationError(DppError):
|
|
17
|
+
"""Raised when a DPP fails a semantic validation rule (fail-fast)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DppMappingError(DppError):
|
|
21
|
+
"""Raised when a DPP cannot be serialized to or parsed from JSON."""
|