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.
Files changed (79) hide show
  1. {elastly-0.1.0 → elastly-0.2.0}/PKG-INFO +48 -1
  2. {elastly-0.1.0 → elastly-0.2.0}/README.md +47 -0
  3. {elastly-0.1.0 → elastly-0.2.0}/elastly/__init__.py +1 -1
  4. {elastly-0.1.0 → elastly-0.2.0}/elastly/api_client.py +1 -1
  5. {elastly-0.1.0 → elastly-0.2.0}/elastly/configuration.py +1 -1
  6. elastly-0.2.0/elastly/connector/__init__.py +84 -0
  7. elastly-0.2.0/elastly/connector/checkpoints.py +53 -0
  8. elastly-0.2.0/elastly/connector/client.py +183 -0
  9. elastly-0.2.0/elastly/connector/ports.py +138 -0
  10. elastly-0.2.0/elastly/connector/runtime.py +431 -0
  11. elastly-0.2.0/elastly/errors.py +292 -0
  12. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2_records_inner.py +0 -23
  13. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3_records_inner.py +1 -11
  14. elastly-0.2.0/elastly/resilience/__init__.py +19 -0
  15. elastly-0.2.0/elastly/resilience/breaker.py +68 -0
  16. elastly-0.2.0/elastly/resilience/idempotency.py +9 -0
  17. elastly-0.2.0/elastly/resilience/retry.py +74 -0
  18. elastly-0.2.0/elastly/serving.py +371 -0
  19. elastly-0.2.0/elastly/transport.py +197 -0
  20. elastly-0.2.0/elastly/webhooks.py +176 -0
  21. {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/PKG-INFO +48 -1
  22. {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/SOURCES.txt +25 -1
  23. {elastly-0.1.0 → elastly-0.2.0}/pyproject.toml +1 -1
  24. {elastly-0.1.0 → elastly-0.2.0}/setup.py +1 -1
  25. elastly-0.2.0/tests/test_breaker.py +88 -0
  26. elastly-0.2.0/tests/test_checkpoints.py +51 -0
  27. elastly-0.2.0/tests/test_connector_client.py +236 -0
  28. elastly-0.2.0/tests/test_connector_sync.py +299 -0
  29. elastly-0.2.0/tests/test_connector_writebacks.py +160 -0
  30. elastly-0.2.0/tests/test_errors.py +115 -0
  31. elastly-0.2.0/tests/test_prices.py +74 -0
  32. elastly-0.2.0/tests/test_retry.py +65 -0
  33. elastly-0.2.0/tests/test_serving.py +265 -0
  34. elastly-0.2.0/tests/test_transport.py +269 -0
  35. elastly-0.2.0/tests/test_webhooks.py +146 -0
  36. {elastly-0.1.0 → elastly-0.2.0}/LICENSE +0 -0
  37. {elastly-0.1.0 → elastly-0.2.0}/elastly/api/__init__.py +0 -0
  38. {elastly-0.1.0 → elastly-0.2.0}/elastly/api/ingest_api.py +0 -0
  39. {elastly-0.1.0 → elastly-0.2.0}/elastly/api/prices_api.py +0 -0
  40. {elastly-0.1.0 → elastly-0.2.0}/elastly/api/writebacks_api.py +0 -0
  41. {elastly-0.1.0 → elastly-0.2.0}/elastly/api_response.py +0 -0
  42. {elastly-0.1.0 → elastly-0.2.0}/elastly/exceptions.py +0 -0
  43. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/__init__.py +0 -0
  44. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/ack_writebacks_response.py +0 -0
  45. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/api_error.py +0 -0
  46. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/api_error_error.py +0 -0
  47. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/batch_created_response.py +0 -0
  48. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/batch_status_response.py +0 -0
  49. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_request.py +0 -0
  50. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_response.py +0 -0
  51. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/claim_writebacks_response_tasks_inner.py +0 -0
  52. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/commit_batch_response.py +0 -0
  53. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_request.py +0 -0
  54. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_request_lines_inner.py +0 -0
  55. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response.py +0 -0
  56. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner.py +0 -0
  57. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of.py +0 -0
  58. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of1.py +0 -0
  59. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_explanation.py +0 -0
  60. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_explanation_drivers_inner.py +0 -0
  61. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/prices_response_lines_inner_one_of_reason_inner.py +0 -0
  62. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request.py +0 -0
  63. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of.py +0 -0
  64. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of1.py +0 -0
  65. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of1_records_inner.py +0 -0
  66. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2.py +0 -0
  67. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of2_records_inner_lines_inner.py +0 -0
  68. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3.py +0 -0
  69. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of3_records_inner_lines_inner.py +0 -0
  70. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_request_one_of_records_inner.py +0 -0
  71. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/stage_records_response.py +0 -0
  72. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/writeback_ack_request.py +0 -0
  73. {elastly-0.1.0 → elastly-0.2.0}/elastly/models/writeback_ack_request_results_inner.py +0 -0
  74. {elastly-0.1.0 → elastly-0.2.0}/elastly/py.typed +0 -0
  75. {elastly-0.1.0 → elastly-0.2.0}/elastly/rest.py +0 -0
  76. {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/dependency_links.txt +0 -0
  77. {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/requires.txt +0 -0
  78. {elastly-0.1.0 → elastly-0.2.0}/elastly.egg-info/top_level.txt +0 -0
  79. {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.1.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.
@@ -15,7 +15,7 @@
15
15
  """ # noqa: E501
16
16
 
17
17
 
18
- __version__ = "0.1.0"
18
+ __version__ = "0.2.0"
19
19
 
20
20
  # Define package exports
21
21
  __all__ = [
@@ -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.1.0/python'
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.1.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: ...