elastly 0.1.0__tar.gz → 0.2.0__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.
- {elastly-0.1.0 → elastly-0.2.0}/PKG-INFO +48 -1
- {elastly-0.1.0 → elastly-0.2.0}/README.md +47 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/__init__.py +1 -1
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api_client.py +1 -1
- {elastly-0.1.0 → elastly-0.2.0}/elastly/configuration.py +1 -1
- elastly-0.2.0/elastly/connector/__init__.py +84 -0
- elastly-0.2.0/elastly/connector/checkpoints.py +53 -0
- elastly-0.2.0/elastly/connector/client.py +183 -0
- elastly-0.2.0/elastly/connector/ports.py +138 -0
- elastly-0.2.0/elastly/connector/runtime.py +431 -0
- elastly-0.2.0/elastly/errors.py +292 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2_records_inner.py +0 -23
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3_records_inner.py +1 -11
- elastly-0.2.0/elastly/resilience/__init__.py +19 -0
- elastly-0.2.0/elastly/resilience/breaker.py +68 -0
- elastly-0.2.0/elastly/resilience/idempotency.py +9 -0
- elastly-0.2.0/elastly/resilience/retry.py +74 -0
- elastly-0.2.0/elastly/serving.py +371 -0
- elastly-0.2.0/elastly/transport.py +197 -0
- elastly-0.2.0/elastly/webhooks.py +176 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/PKG-INFO +48 -1
- {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/SOURCES.txt +25 -1
- {elastly-0.1.0 → elastly-0.2.0}/pyproject.toml +1 -1
- {elastly-0.1.0 → elastly-0.2.0}/setup.py +1 -1
- elastly-0.2.0/tests/test_breaker.py +88 -0
- elastly-0.2.0/tests/test_checkpoints.py +51 -0
- elastly-0.2.0/tests/test_connector_client.py +236 -0
- elastly-0.2.0/tests/test_connector_sync.py +299 -0
- elastly-0.2.0/tests/test_connector_writebacks.py +160 -0
- elastly-0.2.0/tests/test_errors.py +115 -0
- elastly-0.2.0/tests/test_prices.py +74 -0
- elastly-0.2.0/tests/test_retry.py +65 -0
- elastly-0.2.0/tests/test_serving.py +265 -0
- elastly-0.2.0/tests/test_transport.py +269 -0
- elastly-0.2.0/tests/test_webhooks.py +146 -0
- {elastly-0.1.0 → elastly-0.2.0}/LICENSE +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api/__init__.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api/ingest_api.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api/prices_api.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api/writebacks_api.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/api_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/exceptions.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/__init__.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/ack_writebacks_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/api_error.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/api_error_error.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/batch_created_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/batch_status_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_request.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_response_tasks_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/commit_batch_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_request.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_request_lines_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of1.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_explanation.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_explanation_drivers_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_reason_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of1.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of1_records_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2_records_inner_lines_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3_records_inner_lines_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of_records_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_response.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/writeback_ack_request.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/models/writeback_ack_request_results_inner.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/py.typed +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly/rest.py +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/dependency_links.txt +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/requires.txt +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/top_level.txt +0 -0
- {elastly-0.1.0 → elastly-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elastly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Elastly API
|
|
5
5
|
Home-page: https://elastly.io/docs/sdk
|
|
6
6
|
Author: Elastly
|
|
@@ -82,6 +82,53 @@ Every response line is either a priced result (`ok` is true) or a per-line error
|
|
|
82
82
|
|
|
83
83
|
`POST /v1/prices` requires an `Idempotency-Key` header. Use a fresh key for each logical request and reuse the same key on retries. A replay returns the stored response, so a retried call never prices twice.
|
|
84
84
|
|
|
85
|
+
## Price with a deadline and a fallback
|
|
86
|
+
|
|
87
|
+
If you price inside a checkout or quote flow, use the serving layer instead of calling the API directly. It puts a hard deadline on the call (800ms by default), retries transient failures with backoff, trips a circuit breaker when Elastly is down (5 failures, 30 second cooldown), and falls back to a price you supply instead of blocking your flow.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import elastly
|
|
91
|
+
from elastly.serving import FallbackPrice, PricesNamespace, static_fallback
|
|
92
|
+
from elastly.transport import ElastlyTransport
|
|
93
|
+
|
|
94
|
+
configuration = elastly.Configuration(access_token="elastly_live_...")
|
|
95
|
+
client = elastly.ApiClient(configuration)
|
|
96
|
+
prices = PricesNamespace(ElastlyTransport(client))
|
|
97
|
+
|
|
98
|
+
result = prices.get(
|
|
99
|
+
elastly.PricesRequestLinesInner(product_sku="SKU-1042", quantity=25),
|
|
100
|
+
fail_open=static_fallback(lambda line, cause: FallbackPrice(price_cents=1500)),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if result.source == "elastly":
|
|
104
|
+
print(result.price_cents, result.reason_summary)
|
|
105
|
+
elif result.source == "fallback":
|
|
106
|
+
print(result.price_cents, "fallback because", result.cause.reason)
|
|
107
|
+
else:
|
|
108
|
+
print("no price available:", result.cause.reason)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every result carries a `source`: `"elastly"` (a real price), `"fallback"` (your resolver's price), or `"unavailable"` (your resolver returned `None`). Mistakes on your side, like an invalid request or a bad key, always raise a typed error from `elastly.errors` instead of falling back. Prefer an exception over a fallback price? Pass `fail_open=throw_on_failure()`.
|
|
112
|
+
|
|
113
|
+
The transport handles retries and idempotency keys for you, so you do not need to manage the `Idempotency-Key` header yourself on this path.
|
|
114
|
+
|
|
115
|
+
## Verify webhooks
|
|
116
|
+
|
|
117
|
+
Verify the `elastly-signature` header on every webhook before trusting the payload:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from elastly.webhooks import SignatureVerificationError, WebhooksNamespace
|
|
121
|
+
|
|
122
|
+
webhooks = WebhooksNamespace()
|
|
123
|
+
try:
|
|
124
|
+
event = webhooks.verify(raw_body, signature_header, "whsec_...")
|
|
125
|
+
except SignatureVerificationError:
|
|
126
|
+
... # reject with a 400
|
|
127
|
+
print(event.event, event.data)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`verify` checks the HMAC signature in constant time, rejects replays older than 5 minutes, and returns a typed event.
|
|
131
|
+
|
|
85
132
|
## Close the loop
|
|
86
133
|
|
|
87
134
|
Elastly learns from the difference between the price it recommended and the price your team actually charged. When you ingest quotes, echo the `pricingDecisionId` you received from the prices call on the matching quote line. Skip that and the engine has nothing to learn from.
|
|
@@ -61,6 +61,53 @@ Every response line is either a priced result (`ok` is true) or a per-line error
|
|
|
61
61
|
|
|
62
62
|
`POST /v1/prices` requires an `Idempotency-Key` header. Use a fresh key for each logical request and reuse the same key on retries. A replay returns the stored response, so a retried call never prices twice.
|
|
63
63
|
|
|
64
|
+
## Price with a deadline and a fallback
|
|
65
|
+
|
|
66
|
+
If you price inside a checkout or quote flow, use the serving layer instead of calling the API directly. It puts a hard deadline on the call (800ms by default), retries transient failures with backoff, trips a circuit breaker when Elastly is down (5 failures, 30 second cooldown), and falls back to a price you supply instead of blocking your flow.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import elastly
|
|
70
|
+
from elastly.serving import FallbackPrice, PricesNamespace, static_fallback
|
|
71
|
+
from elastly.transport import ElastlyTransport
|
|
72
|
+
|
|
73
|
+
configuration = elastly.Configuration(access_token="elastly_live_...")
|
|
74
|
+
client = elastly.ApiClient(configuration)
|
|
75
|
+
prices = PricesNamespace(ElastlyTransport(client))
|
|
76
|
+
|
|
77
|
+
result = prices.get(
|
|
78
|
+
elastly.PricesRequestLinesInner(product_sku="SKU-1042", quantity=25),
|
|
79
|
+
fail_open=static_fallback(lambda line, cause: FallbackPrice(price_cents=1500)),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if result.source == "elastly":
|
|
83
|
+
print(result.price_cents, result.reason_summary)
|
|
84
|
+
elif result.source == "fallback":
|
|
85
|
+
print(result.price_cents, "fallback because", result.cause.reason)
|
|
86
|
+
else:
|
|
87
|
+
print("no price available:", result.cause.reason)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Every result carries a `source`: `"elastly"` (a real price), `"fallback"` (your resolver's price), or `"unavailable"` (your resolver returned `None`). Mistakes on your side, like an invalid request or a bad key, always raise a typed error from `elastly.errors` instead of falling back. Prefer an exception over a fallback price? Pass `fail_open=throw_on_failure()`.
|
|
91
|
+
|
|
92
|
+
The transport handles retries and idempotency keys for you, so you do not need to manage the `Idempotency-Key` header yourself on this path.
|
|
93
|
+
|
|
94
|
+
## Verify webhooks
|
|
95
|
+
|
|
96
|
+
Verify the `elastly-signature` header on every webhook before trusting the payload:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from elastly.webhooks import SignatureVerificationError, WebhooksNamespace
|
|
100
|
+
|
|
101
|
+
webhooks = WebhooksNamespace()
|
|
102
|
+
try:
|
|
103
|
+
event = webhooks.verify(raw_body, signature_header, "whsec_...")
|
|
104
|
+
except SignatureVerificationError:
|
|
105
|
+
... # reject with a 400
|
|
106
|
+
print(event.event, event.data)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`verify` checks the HMAC signature in constant time, rejects replays older than 5 minutes, and returns a typed event.
|
|
110
|
+
|
|
64
111
|
## Close the loop
|
|
65
112
|
|
|
66
113
|
Elastly learns from the difference between the price it recommended and the price your team actually charged. When you ingest quotes, echo the `pricingDecisionId` you received from the prices call on the matching quote line. Skip that and the engine has nothing to learn from.
|
|
@@ -92,7 +92,7 @@ class ApiClient:
|
|
|
92
92
|
self.default_headers[header_name] = header_value
|
|
93
93
|
self.cookie = cookie
|
|
94
94
|
# Set default User-Agent.
|
|
95
|
-
self.user_agent = 'OpenAPI-Generator/0.
|
|
95
|
+
self.user_agent = 'OpenAPI-Generator/0.2.0/python'
|
|
96
96
|
self.client_side_validation = configuration.client_side_validation
|
|
97
97
|
|
|
98
98
|
def __enter__(self):
|
|
@@ -534,7 +534,7 @@ class Configuration:
|
|
|
534
534
|
"OS: {env}\n"\
|
|
535
535
|
"Python Version: {pyversion}\n"\
|
|
536
536
|
"Version of the API: 1.0.0\n"\
|
|
537
|
-
"SDK Package Version: 0.
|
|
537
|
+
"SDK Package Version: 0.2.0".\
|
|
538
538
|
format(env=sys.platform, pyversion=sys.version)
|
|
539
539
|
|
|
540
540
|
def get_host_settings(self) -> List[HostSetting]:
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Connector framework: define a connector once, get resumable sync and lease-safe write-backs."""
|
|
2
|
+
|
|
3
|
+
from elastly.connector.checkpoints import FileCheckpointStore, InMemoryCheckpointStore
|
|
4
|
+
from elastly.connector.client import (
|
|
5
|
+
DEFAULT_DRAIN_TIMEOUT_MS,
|
|
6
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
7
|
+
MAX_RECORDS_PER_CALL,
|
|
8
|
+
DrainTimeoutError,
|
|
9
|
+
ElastlyConnectorClient,
|
|
10
|
+
IngestClient,
|
|
11
|
+
WritebacksClient,
|
|
12
|
+
connector_client,
|
|
13
|
+
)
|
|
14
|
+
from elastly.connector.ports import (
|
|
15
|
+
INGEST_ENTITIES,
|
|
16
|
+
AckOutcome,
|
|
17
|
+
BatchCreated,
|
|
18
|
+
BatchStatus,
|
|
19
|
+
CheckpointStore,
|
|
20
|
+
CommitResult,
|
|
21
|
+
ConnectorClient,
|
|
22
|
+
ConnectorIngestClient,
|
|
23
|
+
ConnectorWritebacksClient,
|
|
24
|
+
ElastlyConnector,
|
|
25
|
+
IngestEntity,
|
|
26
|
+
Page,
|
|
27
|
+
Record,
|
|
28
|
+
StageResult,
|
|
29
|
+
WritebackResult,
|
|
30
|
+
WritebackTask,
|
|
31
|
+
)
|
|
32
|
+
from elastly.connector.runtime import (
|
|
33
|
+
BATCH_CHECKPOINT_KEY,
|
|
34
|
+
DEFAULT_FETCH_RETRY_POLICY,
|
|
35
|
+
DEFAULT_LEASE_SAFETY_MS,
|
|
36
|
+
ConnectorDefinition,
|
|
37
|
+
ConnectorRuntimeEvent,
|
|
38
|
+
ConnectorSyncError,
|
|
39
|
+
ConnectorWarning,
|
|
40
|
+
DrainReport,
|
|
41
|
+
FetchRetryPolicy,
|
|
42
|
+
SyncReport,
|
|
43
|
+
define_connector,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AckOutcome",
|
|
48
|
+
"BATCH_CHECKPOINT_KEY",
|
|
49
|
+
"BatchCreated",
|
|
50
|
+
"BatchStatus",
|
|
51
|
+
"CheckpointStore",
|
|
52
|
+
"CommitResult",
|
|
53
|
+
"ConnectorClient",
|
|
54
|
+
"ConnectorDefinition",
|
|
55
|
+
"ConnectorIngestClient",
|
|
56
|
+
"ConnectorRuntimeEvent",
|
|
57
|
+
"ConnectorSyncError",
|
|
58
|
+
"ConnectorWarning",
|
|
59
|
+
"ConnectorWritebacksClient",
|
|
60
|
+
"DEFAULT_DRAIN_TIMEOUT_MS",
|
|
61
|
+
"DEFAULT_FETCH_RETRY_POLICY",
|
|
62
|
+
"DEFAULT_LEASE_SAFETY_MS",
|
|
63
|
+
"DEFAULT_POLL_INTERVAL_MS",
|
|
64
|
+
"DrainReport",
|
|
65
|
+
"DrainTimeoutError",
|
|
66
|
+
"ElastlyConnector",
|
|
67
|
+
"ElastlyConnectorClient",
|
|
68
|
+
"FetchRetryPolicy",
|
|
69
|
+
"FileCheckpointStore",
|
|
70
|
+
"INGEST_ENTITIES",
|
|
71
|
+
"IngestClient",
|
|
72
|
+
"IngestEntity",
|
|
73
|
+
"InMemoryCheckpointStore",
|
|
74
|
+
"MAX_RECORDS_PER_CALL",
|
|
75
|
+
"Page",
|
|
76
|
+
"Record",
|
|
77
|
+
"StageResult",
|
|
78
|
+
"SyncReport",
|
|
79
|
+
"WritebackResult",
|
|
80
|
+
"WritebackTask",
|
|
81
|
+
"WritebacksClient",
|
|
82
|
+
"connector_client",
|
|
83
|
+
"define_connector",
|
|
84
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Checkpoint stores: cursor persistence so an interrupted sync resumes, not restarts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryCheckpointStore:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._cursors: Dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
def get(self, key: str) -> Optional[str]:
|
|
16
|
+
return self._cursors.get(key)
|
|
17
|
+
|
|
18
|
+
def set(self, key: str, cursor: Optional[str]) -> None:
|
|
19
|
+
if cursor is None:
|
|
20
|
+
self._cursors.pop(key, None)
|
|
21
|
+
else:
|
|
22
|
+
self._cursors[key] = cursor
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileCheckpointStore:
|
|
26
|
+
"""JSON-file store; writes go to a temp file then an atomic rename."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, file_path: Union[str, "os.PathLike[str]"]) -> None:
|
|
29
|
+
self._path = Path(file_path)
|
|
30
|
+
|
|
31
|
+
def get(self, key: str) -> Optional[str]:
|
|
32
|
+
return self._read().get(key)
|
|
33
|
+
|
|
34
|
+
def set(self, key: str, cursor: Optional[str]) -> None:
|
|
35
|
+
cursors = self._read()
|
|
36
|
+
if cursor is None:
|
|
37
|
+
cursors.pop(key, None)
|
|
38
|
+
else:
|
|
39
|
+
cursors[key] = cursor
|
|
40
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
temporary_path = self._path.with_name(f"{self._path.name}.tmp")
|
|
42
|
+
temporary_path.write_text(json.dumps(cursors, indent=2) + "\n", encoding="utf-8")
|
|
43
|
+
os.replace(temporary_path, self._path)
|
|
44
|
+
|
|
45
|
+
def _read(self) -> Dict[str, str]:
|
|
46
|
+
try:
|
|
47
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return {}
|
|
50
|
+
parsed = json.loads(raw)
|
|
51
|
+
if not isinstance(parsed, dict):
|
|
52
|
+
return {}
|
|
53
|
+
return {key: value for key, value in parsed.items() if isinstance(value, str)}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Concrete connector client composed over the generated ingest and write-back APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, List, Optional, Sequence, TypeVar
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from elastly.api.ingest_api import IngestApi
|
|
10
|
+
from elastly.api.writebacks_api import WritebacksApi
|
|
11
|
+
from elastly.connector.ports import (
|
|
12
|
+
AckOutcome,
|
|
13
|
+
BatchCreated,
|
|
14
|
+
BatchStatus,
|
|
15
|
+
CommitResult,
|
|
16
|
+
IngestEntity,
|
|
17
|
+
Record,
|
|
18
|
+
StageResult,
|
|
19
|
+
WritebackResult,
|
|
20
|
+
WritebackTask,
|
|
21
|
+
)
|
|
22
|
+
from elastly.errors import ElastlyError
|
|
23
|
+
from elastly.exceptions import ApiException
|
|
24
|
+
from elastly.models.claim_writebacks_request import ClaimWritebacksRequest
|
|
25
|
+
from elastly.models.stage_records_request import StageRecordsRequest
|
|
26
|
+
from elastly.models.writeback_ack_request import WritebackAckRequest
|
|
27
|
+
from elastly.models.writeback_ack_request_results_inner import WritebackAckRequestResultsInner
|
|
28
|
+
from elastly.transport import error_from_api_exception
|
|
29
|
+
|
|
30
|
+
MAX_RECORDS_PER_CALL = 1000
|
|
31
|
+
DEFAULT_POLL_INTERVAL_MS = 2_000.0
|
|
32
|
+
DEFAULT_DRAIN_TIMEOUT_MS = 600_000.0
|
|
33
|
+
|
|
34
|
+
R = TypeVar("R")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DrainTimeoutError(ElastlyError):
|
|
38
|
+
def __init__(self, batch_id: str, timeout_ms: float) -> None:
|
|
39
|
+
super().__init__(f"Batch {batch_id} did not drain within {timeout_ms:g}ms.", None)
|
|
40
|
+
self.batch_id = batch_id
|
|
41
|
+
self.timeout_ms = timeout_ms
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _monotonic_ms() -> float:
|
|
45
|
+
return time.monotonic() * 1000.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_sleep(ms: float) -> None:
|
|
49
|
+
if ms > 0:
|
|
50
|
+
time.sleep(ms / 1000.0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _call(operation: Callable[[], R]) -> R:
|
|
54
|
+
try:
|
|
55
|
+
return operation()
|
|
56
|
+
except ApiException as exc:
|
|
57
|
+
error, _ = error_from_api_exception(exc)
|
|
58
|
+
raise error from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IngestClient:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
api: IngestApi,
|
|
65
|
+
*,
|
|
66
|
+
clock: Optional[Callable[[], float]] = None,
|
|
67
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._api = api
|
|
70
|
+
self._clock = clock if clock is not None else _monotonic_ms
|
|
71
|
+
self._sleep = sleep if sleep is not None else _default_sleep
|
|
72
|
+
|
|
73
|
+
def open_batch(self) -> BatchCreated:
|
|
74
|
+
response = _call(self._api.create_ingest_batch)
|
|
75
|
+
return BatchCreated(batch_id=response.batch_id, connector_id=response.connector_id)
|
|
76
|
+
|
|
77
|
+
def stage(
|
|
78
|
+
self, batch_id: str, entity: IngestEntity, records: Sequence[Record]
|
|
79
|
+
) -> StageResult:
|
|
80
|
+
staged = 0
|
|
81
|
+
for offset in range(0, len(records), MAX_RECORDS_PER_CALL):
|
|
82
|
+
chunk = records[offset:offset + MAX_RECORDS_PER_CALL]
|
|
83
|
+
# model_construct skips client-side validation on purpose: the generated
|
|
84
|
+
# date-time regex validators run after pydantic coerces the string to a
|
|
85
|
+
# datetime, so every valid RFC3339 quote/order timestamp is rejected.
|
|
86
|
+
# The server validates the payload; this matches the TypeScript SDK.
|
|
87
|
+
request = StageRecordsRequest.model_construct(
|
|
88
|
+
actual_instance={"entity": entity, "records": [dict(record) for record in chunk]}
|
|
89
|
+
)
|
|
90
|
+
response = _call(lambda: self._api.stage_ingest_records(UUID(batch_id), request))
|
|
91
|
+
staged += response.staged
|
|
92
|
+
return StageResult(staged=staged)
|
|
93
|
+
|
|
94
|
+
def commit(self, batch_id: str) -> CommitResult:
|
|
95
|
+
response = _call(lambda: self._api.commit_ingest_batch(UUID(batch_id)))
|
|
96
|
+
return CommitResult(job_id=response.job_id)
|
|
97
|
+
|
|
98
|
+
def status(self, batch_id: str) -> BatchStatus:
|
|
99
|
+
response = _call(lambda: self._api.get_ingest_batch_status(UUID(batch_id)))
|
|
100
|
+
return BatchStatus(
|
|
101
|
+
status=response.status,
|
|
102
|
+
entities=tuple(response.entities),
|
|
103
|
+
record_count=response.record_count,
|
|
104
|
+
skipped_over_cap=response.skipped_over_cap,
|
|
105
|
+
error=response.error,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def wait_for_drain(
|
|
109
|
+
self,
|
|
110
|
+
batch_id: str,
|
|
111
|
+
*,
|
|
112
|
+
poll_interval_ms: Optional[float] = None,
|
|
113
|
+
timeout_ms: Optional[float] = None,
|
|
114
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
115
|
+
) -> BatchStatus:
|
|
116
|
+
poll = max(
|
|
117
|
+
1.0, poll_interval_ms if poll_interval_ms is not None else DEFAULT_POLL_INTERVAL_MS
|
|
118
|
+
)
|
|
119
|
+
timeout = max(0.0, timeout_ms if timeout_ms is not None else DEFAULT_DRAIN_TIMEOUT_MS)
|
|
120
|
+
sleep_fn = sleep if sleep is not None else self._sleep
|
|
121
|
+
started = self._clock()
|
|
122
|
+
while True:
|
|
123
|
+
status = self.status(batch_id)
|
|
124
|
+
if status.status in ("drained", "failed"):
|
|
125
|
+
return status
|
|
126
|
+
remaining = timeout - (self._clock() - started)
|
|
127
|
+
if remaining <= 0:
|
|
128
|
+
raise DrainTimeoutError(batch_id, timeout)
|
|
129
|
+
sleep_fn(min(poll, remaining))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WritebacksClient:
|
|
133
|
+
def __init__(self, api: WritebacksApi) -> None:
|
|
134
|
+
self._api = api
|
|
135
|
+
|
|
136
|
+
def claim(self, limit: Optional[int] = None) -> Sequence[WritebackTask]:
|
|
137
|
+
request = ClaimWritebacksRequest() if limit is None else ClaimWritebacksRequest(limit=limit)
|
|
138
|
+
response = _call(lambda: self._api.claim_writebacks(request))
|
|
139
|
+
return [
|
|
140
|
+
WritebackTask(
|
|
141
|
+
id=task.id,
|
|
142
|
+
product_sku=task.product_sku,
|
|
143
|
+
product_external_id=task.product_external_id,
|
|
144
|
+
price_cents=task.price_cents,
|
|
145
|
+
currency=task.currency,
|
|
146
|
+
lease_until=task.lease_until,
|
|
147
|
+
exchange_rate_to_base=task.exchange_rate_to_base,
|
|
148
|
+
reason_summary=task.reason_summary,
|
|
149
|
+
pricing_decision_id=task.pricing_decision_id,
|
|
150
|
+
target=task.target,
|
|
151
|
+
)
|
|
152
|
+
for task in response.tasks
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
def ack(self, results: Sequence[WritebackResult]) -> AckOutcome:
|
|
156
|
+
if not results:
|
|
157
|
+
return AckOutcome(applied=0, failed=0, expired=0)
|
|
158
|
+
inner: List[WritebackAckRequestResultsInner] = [
|
|
159
|
+
WritebackAckRequestResultsInner(id=result.id, ok=result.ok, error=result.error)
|
|
160
|
+
for result in results
|
|
161
|
+
]
|
|
162
|
+
response = _call(lambda: self._api.ack_writebacks(WritebackAckRequest(results=inner)))
|
|
163
|
+
return AckOutcome(
|
|
164
|
+
applied=response.applied, failed=response.failed, expired=response.expired
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ElastlyConnectorClient:
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
api_client: Any,
|
|
172
|
+
*,
|
|
173
|
+
ingest: Optional[IngestClient] = None,
|
|
174
|
+
writebacks: Optional[WritebacksClient] = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.ingest = ingest if ingest is not None else IngestClient(IngestApi(api_client))
|
|
177
|
+
self.writebacks = (
|
|
178
|
+
writebacks if writebacks is not None else WritebacksClient(WritebacksApi(api_client))
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def connector_client(api_client: Any) -> ElastlyConnectorClient:
|
|
183
|
+
return ElastlyConnectorClient(api_client)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Narrow ports the connector runtime depends on, mirroring the TypeScript SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import (
|
|
7
|
+
Any,
|
|
8
|
+
Callable,
|
|
9
|
+
Generic,
|
|
10
|
+
Literal,
|
|
11
|
+
Mapping,
|
|
12
|
+
Optional,
|
|
13
|
+
Protocol,
|
|
14
|
+
Sequence,
|
|
15
|
+
Tuple,
|
|
16
|
+
TypeVar,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
IngestEntity = Literal["product", "customer", "quote", "order"]
|
|
20
|
+
|
|
21
|
+
INGEST_ENTITIES: Tuple[IngestEntity, ...] = ("product", "customer", "quote", "order")
|
|
22
|
+
|
|
23
|
+
Record = Mapping[str, Any]
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Page(Generic[T]):
|
|
30
|
+
records: Sequence[T]
|
|
31
|
+
next_cursor: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BatchCreated:
|
|
36
|
+
batch_id: str
|
|
37
|
+
connector_id: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class StageResult:
|
|
42
|
+
staged: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CommitResult:
|
|
47
|
+
job_id: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class BatchStatus:
|
|
52
|
+
status: str
|
|
53
|
+
entities: Sequence[str] = ()
|
|
54
|
+
record_count: int = 0
|
|
55
|
+
skipped_over_cap: int = 0
|
|
56
|
+
error: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class WritebackTask:
|
|
61
|
+
id: str
|
|
62
|
+
product_sku: str
|
|
63
|
+
product_external_id: str
|
|
64
|
+
price_cents: float
|
|
65
|
+
currency: str
|
|
66
|
+
lease_until: str
|
|
67
|
+
exchange_rate_to_base: Optional[float] = None
|
|
68
|
+
reason_summary: Optional[str] = None
|
|
69
|
+
pricing_decision_id: Optional[str] = None
|
|
70
|
+
target: Mapping[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class WritebackResult:
|
|
75
|
+
id: str
|
|
76
|
+
ok: bool
|
|
77
|
+
error: Optional[str] = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class AckOutcome:
|
|
82
|
+
applied: int
|
|
83
|
+
failed: int
|
|
84
|
+
expired: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ElastlyConnector(Protocol):
|
|
88
|
+
"""The user-implemented connector port.
|
|
89
|
+
|
|
90
|
+
``fetch_quotes``, ``fetch_orders``, and ``apply_price`` are optional
|
|
91
|
+
capabilities: the runtime discovers them with ``getattr`` so a connector
|
|
92
|
+
only implements what its source system supports.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def fetch_products(self, cursor: Optional[str]) -> Page[Record]: ...
|
|
96
|
+
|
|
97
|
+
def fetch_customers(self, cursor: Optional[str]) -> Page[Record]: ...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CheckpointStore(Protocol):
|
|
101
|
+
def get(self, key: str) -> Optional[str]: ...
|
|
102
|
+
|
|
103
|
+
def set(self, key: str, cursor: Optional[str]) -> None: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ConnectorIngestClient(Protocol):
|
|
107
|
+
def open_batch(self) -> BatchCreated: ...
|
|
108
|
+
|
|
109
|
+
def stage(
|
|
110
|
+
self, batch_id: str, entity: IngestEntity, records: Sequence[Record]
|
|
111
|
+
) -> StageResult: ...
|
|
112
|
+
|
|
113
|
+
def commit(self, batch_id: str) -> CommitResult: ...
|
|
114
|
+
|
|
115
|
+
def status(self, batch_id: str) -> BatchStatus: ...
|
|
116
|
+
|
|
117
|
+
def wait_for_drain(
|
|
118
|
+
self,
|
|
119
|
+
batch_id: str,
|
|
120
|
+
*,
|
|
121
|
+
poll_interval_ms: Optional[float] = None,
|
|
122
|
+
timeout_ms: Optional[float] = None,
|
|
123
|
+
sleep: Optional[Callable[[float], None]] = None,
|
|
124
|
+
) -> BatchStatus: ...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ConnectorWritebacksClient(Protocol):
|
|
128
|
+
def claim(self, limit: Optional[int] = None) -> Sequence[WritebackTask]: ...
|
|
129
|
+
|
|
130
|
+
def ack(self, results: Sequence[WritebackResult]) -> AckOutcome: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ConnectorClient(Protocol):
|
|
134
|
+
@property
|
|
135
|
+
def ingest(self) -> ConnectorIngestClient: ...
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def writebacks(self) -> ConnectorWritebacksClient: ...
|