ergon-framework-python 0.2.4__py3-none-any.whl → 0.3.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.
ergon/__init__.py CHANGED
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.2.4"
13
+ __version__ = "0.3.0"
@@ -1,3 +1,7 @@
1
+ import os
2
+ import socket
3
+ from typing import Optional
4
+
1
5
  from .models import ErgonPlatformClient
2
6
 
3
7
  _ERGON_PLATFORM_IMPORT_ERROR = (
@@ -14,13 +18,35 @@ def _get_ergon_client():
14
18
  return ErgonClient
15
19
 
16
20
 
17
- def create_ergon_client(config: ErgonPlatformClient):
21
+ def default_consumer_id() -> str:
22
+ """One identity per worker process: ``<hostname>:<pid>``.
23
+
24
+ Pull only phases fence item claims by this value; two processes must never
25
+ share it, two batches of the same process should.
26
+ """
27
+ host = socket.gethostname().split(".")[0] or "worker"
28
+ safe_host = "".join(ch if (ch.isalnum() or ch in "._:@-") else "-" for ch in host)
29
+ return f"{safe_host}:{os.getpid()}"[:128]
30
+
31
+
32
+ def create_ergon_client(config: ErgonPlatformClient, *, consumer_id: Optional[str] = None):
33
+ """Build the SDK client. ``consumer_id`` adds ``X-Ergon-Consumer-Id`` to every request."""
18
34
  ErgonClient = _get_ergon_client()
35
+ if consumer_id is None:
36
+ return ErgonClient(
37
+ client_id=config.client_id,
38
+ client_secret=config.client_secret,
39
+ base_url=config.base_url,
40
+ company_id=config.company_id,
41
+ timeout=config.timeout,
42
+ max_retries=config.max_retries,
43
+ )
19
44
  return ErgonClient(
20
45
  client_id=config.client_id,
21
46
  client_secret=config.client_secret,
22
47
  base_url=config.base_url,
23
48
  company_id=config.company_id,
49
+ consumer_id=consumer_id,
24
50
  timeout=config.timeout,
25
51
  max_retries=config.max_retries,
26
52
  )
@@ -0,0 +1,88 @@
1
+ """Consumer mode resolution shared by the sync and async workflow connectors.
2
+
3
+ A connector consumes one phase. Standard phases are served by the historical
4
+ list-and-claim loop; *Pull only* phases by ``POST /phases/{id}/items/pull``
5
+ under a consumer id. ``mode="auto"`` reads the phase kind once, on the first
6
+ call that needs it, and the connector then behaves like the matching kind for
7
+ the rest of its life. Phase kind is fixed at creation, so there is nothing to
8
+ re-check later.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any, Callable, Optional
13
+
14
+ from .._client import create_ergon_client, default_consumer_id
15
+ from ..models import ErgonPlatformClient
16
+ from ._operations import ErgonPlatformWorkflowsService
17
+ from .models import ErgonPlatformConsumerConfig
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ DEFAULT_NACK_RELEASE_DELAY_SECONDS = 10
22
+ DEFAULT_NACK_REASON = "nack_transaction"
23
+
24
+
25
+ class ConsumerModeState:
26
+ """Lazily decides list-claim vs pull for one connector and swaps in the fenced client."""
27
+
28
+ def __init__(
29
+ self,
30
+ client_config: ErgonPlatformClient,
31
+ consumer_config: Optional[ErgonPlatformConsumerConfig],
32
+ service: ErgonPlatformWorkflowsService,
33
+ set_client: Callable[[Any], None],
34
+ ) -> None:
35
+ self._client_config = client_config
36
+ self._consumer_config = consumer_config
37
+ self._service = service
38
+ self._set_client = set_client
39
+ self._pull: Optional[bool] = None
40
+ self.consumer_id: Optional[str] = None
41
+ if consumer_config is not None and consumer_config.mode == "pull":
42
+ self._enable_pull()
43
+ self._pull = True
44
+
45
+ @property
46
+ def resolved(self) -> Optional[bool]:
47
+ return self._pull
48
+
49
+ def is_pull(self) -> bool:
50
+ if self._pull is not None:
51
+ return self._pull
52
+ config = self._consumer_config
53
+ if config is None:
54
+ return False
55
+ if config.mode == "list_claim":
56
+ pull = False
57
+ else:
58
+ try:
59
+ pull = self._service.is_pull_phase(config.workflow_id, config.phase_id)
60
+ except Exception:
61
+ logger.warning(
62
+ "Could not read the kind of phase %s; consuming it as a standard phase. "
63
+ "Set consumer_config.mode explicitly to silence this.",
64
+ config.phase_id,
65
+ exc_info=True,
66
+ )
67
+ pull = False
68
+ if pull:
69
+ self._enable_pull()
70
+ self._pull = pull
71
+ return pull
72
+
73
+ def _enable_pull(self) -> None:
74
+ config = self._consumer_config
75
+ assert config is not None
76
+ self.consumer_id = config.consumer_id or default_consumer_id()
77
+ fenced = create_ergon_client(self._client_config, consumer_id=self.consumer_id)
78
+ previous = self._service.client
79
+ self._service.client = fenced
80
+ self._set_client(fenced)
81
+ if previous is not fenced:
82
+ close = getattr(previous, "close", None)
83
+ if callable(close):
84
+ try:
85
+ close()
86
+ except Exception:
87
+ logger.debug("Closing the unfenced client failed", exc_info=True)
88
+ logger.info("Phase %s is Pull only; consuming with consumer id %s", config.phase_id, self.consumer_id)
@@ -1,5 +1,7 @@
1
+ import logging
1
2
  import math
2
3
  import mimetypes
4
+ import warnings
3
5
  from pathlib import Path
4
6
  from typing import Any, Dict, List, Optional
5
7
 
@@ -21,6 +23,15 @@ from .utils import (
21
23
  latest_status_entry,
22
24
  )
23
25
 
26
+ logger = logging.getLogger(__name__)
27
+
28
+ RELEASE_DELAY_DEPRECATION = (
29
+ "release_item(delay_seconds=...) relies on the item override "
30
+ "visibility_timeout_on_release_minutes, which the platform removes on 2026-11-27 "
31
+ "(ergon-platform #664). Make the phase 'Pull only' and the same call becomes a defer, "
32
+ "or call defer_transaction / fail_transaction directly."
33
+ )
34
+
24
35
 
25
36
  class ErgonPlatformWorkflowsService:
26
37
  """Workflows HTTP, pagination, claim, and attachment service."""
@@ -142,10 +153,16 @@ class ErgonPlatformWorkflowsService:
142
153
  delay_seconds: Optional[int] = None,
143
154
  **fields: Any,
144
155
  ) -> Any:
156
+ """Release a claim on a standard phase; ``delay_seconds`` hides the item once.
157
+
158
+ The delay uses the deprecated item override; see :data:`RELEASE_DELAY_DEPRECATION`.
159
+ """
145
160
  if delay_seconds is not None:
146
161
  if delay_seconds < 0:
147
162
  raise ValueError("delay_seconds must be a non-negative integer")
148
163
  if delay_seconds > 0:
164
+ warnings.warn(RELEASE_DELAY_DEPRECATION, DeprecationWarning, stacklevel=3)
165
+ logger.warning(RELEASE_DELAY_DEPRECATION)
149
166
  release_minutes = math.ceil(delay_seconds / 60)
150
167
  self.client.workflows.items.update(
151
168
  item_id,
@@ -153,6 +170,74 @@ class ErgonPlatformWorkflowsService:
153
170
  )
154
171
  return self.client.workflows.items.release(item_id, {**(data or {}), **fields})
155
172
 
173
+ # -- Pull only phases ---------------------------------------------------
174
+
175
+ def get_phase(self, workflow_id: str, phase_id: str) -> Any:
176
+ """The phase row from ``GET /workflows/{id}/phases`` (there is no single-phase GET)."""
177
+ phases = extract_items(self.client.workflows.workflow(workflow_id).phases())
178
+ for phase in phases:
179
+ if str(get_value(phase, "id", "")) == str(phase_id):
180
+ return phase
181
+ raise LookupError(f"Phase {phase_id} not found in workflow {workflow_id}")
182
+
183
+ def is_pull_phase(self, workflow_id: str, phase_id: str) -> bool:
184
+ return str(get_value(self.get_phase(workflow_id, phase_id), "kind", "")) == "pull"
185
+
186
+ def pull_items(
187
+ self,
188
+ workflow_id: str,
189
+ phase_id: str,
190
+ *,
191
+ limit: int,
192
+ filters: Optional[List[Dict[str, Any]]] = None,
193
+ wait_seconds: Optional[int] = None,
194
+ ) -> List[Transaction]:
195
+ """``POST /phases/{id}/items/pull``: atomically claim up to ``limit`` visible items."""
196
+ body: Dict[str, Any] = {"max": max(1, min(int(limit), 100))}
197
+ if filters:
198
+ body["filter"] = {"filters": list(filters)}
199
+ if wait_seconds:
200
+ body["wait_seconds"] = int(wait_seconds)
201
+ response = self.client.workflows.phases.pull_items(phase_id, body)
202
+ return [item_to_transaction(item, workflow_id) for item in extract_items(response)]
203
+
204
+ def complete_item(self, item_id: str, *, reason: Optional[str] = None) -> Any:
205
+ body: Dict[str, Any] = {}
206
+ if reason:
207
+ body["reason"] = reason
208
+ return self.client.workflows.items.complete(item_id, body)
209
+
210
+ def fail_item(
211
+ self,
212
+ item_id: str,
213
+ *,
214
+ reason: str,
215
+ delay_seconds: Optional[int] = None,
216
+ details: Optional[str] = None,
217
+ ) -> Any:
218
+ body: Dict[str, Any] = {"reason": reason}
219
+ if delay_seconds is not None:
220
+ if delay_seconds < 0:
221
+ raise ValueError("delay_seconds must be a non-negative integer")
222
+ body["delay_seconds"] = int(delay_seconds)
223
+ if details:
224
+ body["details"] = details
225
+ return self.client.workflows.items.fail(item_id, body)
226
+
227
+ def defer_item(self, item_id: str, *, delay_seconds: int, reason: Optional[str] = None) -> Any:
228
+ if delay_seconds < 1:
229
+ raise ValueError("defer requires delay_seconds >= 1")
230
+ body: Dict[str, Any] = {"delay_seconds": int(delay_seconds)}
231
+ if reason:
232
+ body["reason"] = reason
233
+ return self.client.workflows.items.defer(item_id, body)
234
+
235
+ def extend_claim(self, item_id: str, *, seconds: Optional[int] = None) -> Any:
236
+ body: Dict[str, Any] = {}
237
+ if seconds is not None:
238
+ body["seconds"] = int(seconds)
239
+ return self.client.workflows.items.extend_claim(item_id, body)
240
+
156
241
  def get_pipeline_result(
157
242
  self,
158
243
  workflow_id: str,
@@ -6,6 +6,7 @@ from ...connector import AsyncConnector
6
6
  from ...transaction import Transaction
7
7
  from .._client import create_ergon_client
8
8
  from ..models import ErgonPlatformClient
9
+ from ._mode import DEFAULT_NACK_REASON, DEFAULT_NACK_RELEASE_DELAY_SECONDS, ConsumerModeState
9
10
  from ._operations import ErgonPlatformWorkflowsService
10
11
  from .models import (
11
12
  CreateItemPayload,
@@ -29,6 +30,21 @@ class AsyncErgonPlatformConnector(AsyncConnector):
29
30
  self._operations = self.service
30
31
  self._consumer_config = consumer_config
31
32
  self._producer_config = producer_config or ErgonPlatformProducerConfig()
33
+ self._mode = ConsumerModeState(client, consumer_config, self.service, self._set_client)
34
+
35
+ def _set_client(self, client: Any) -> None:
36
+ self.client = client
37
+
38
+ async def is_pull_async(self) -> bool:
39
+ """True when the consumed phase is Pull only (resolved once, on first use)."""
40
+ if self._mode.resolved is not None:
41
+ return self._mode.resolved
42
+ return await asyncio.to_thread(self._mode.is_pull)
43
+
44
+ @property
45
+ def consumer_id(self) -> Optional[str]:
46
+ """The ``X-Ergon-Consumer-Id`` this connector claims under; None on standard phases."""
47
+ return self._mode.consumer_id
32
48
 
33
49
  async def fetch_transactions_async(
34
50
  self,
@@ -41,6 +57,19 @@ class AsyncErgonPlatformConnector(AsyncConnector):
41
57
 
42
58
  config = self._consumer_config
43
59
  limit = batch_size or config.batch_size
60
+ if await self.is_pull_async():
61
+ if kwargs:
62
+ logger.debug("Ignoring list params %s on Pull only phase %s", sorted(kwargs), config.phase_id)
63
+ return await asyncio.to_thread(
64
+ lambda: self._operations.pull_items(
65
+ config.workflow_id,
66
+ config.phase_id,
67
+ limit=limit,
68
+ filters=config.pull_filters,
69
+ wait_seconds=config.pull_wait_seconds,
70
+ )
71
+ )
72
+
44
73
  params: Dict[str, Any] = {**config.list_params, **kwargs}
45
74
  if config.unassigned:
46
75
  params.pop("assigned_to", None)
@@ -117,12 +146,15 @@ class AsyncErgonPlatformConnector(AsyncConnector):
117
146
  )
118
147
 
119
148
  async def ack_transaction(self, transaction: Transaction, phase_id: Optional[str] = None) -> None:
149
+ """Route to ``phase_id`` / ``ack_phase_id``; on a Pull only phase with no target, complete the stay."""
120
150
  target_phase = phase_id
121
151
  if target_phase is None and self._consumer_config is not None:
122
152
  target_phase = self._consumer_config.ack_phase_id
123
- if not target_phase:
153
+ if target_phase:
154
+ await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
124
155
  return
125
- await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
156
+ if await self.is_pull_async():
157
+ await asyncio.to_thread(lambda: self._operations.complete_item(transaction.id))
126
158
 
127
159
  async def release_item(
128
160
  self,
@@ -132,6 +164,9 @@ class AsyncErgonPlatformConnector(AsyncConnector):
132
164
  delay_seconds: Optional[int] = None,
133
165
  **fields: Any,
134
166
  ) -> Any:
167
+ """Give the item back. ``delay_seconds`` > 0 is a defer on Pull only phases."""
168
+ if delay_seconds and await self.is_pull_async():
169
+ return await asyncio.to_thread(lambda: self._operations.defer_item(item_id, delay_seconds=delay_seconds))
135
170
  return await asyncio.to_thread(
136
171
  lambda: self._operations.release_item(
137
172
  item_id,
@@ -141,9 +176,29 @@ class AsyncErgonPlatformConnector(AsyncConnector):
141
176
  )
142
177
  )
143
178
 
144
- async def nack_transaction(self, transaction: Transaction, requeue: bool = True, delay_seconds: int = 10) -> None:
179
+ async def nack_transaction(
180
+ self,
181
+ transaction: Transaction,
182
+ requeue: bool = True,
183
+ delay_seconds: Optional[int] = None,
184
+ *,
185
+ reason: Optional[str] = None,
186
+ details: Optional[str] = None,
187
+ ) -> None:
188
+ """Hand the item back after a failure; see :meth:`ErgonPlatformConnector.nack_transaction`."""
145
189
  if requeue:
146
- await asyncio.to_thread(lambda: self._operations.release_item(transaction.id, delay_seconds=delay_seconds))
190
+ if await self.is_pull_async():
191
+ await asyncio.to_thread(
192
+ lambda: self._operations.fail_item(
193
+ transaction.id,
194
+ reason=reason or DEFAULT_NACK_REASON,
195
+ delay_seconds=delay_seconds,
196
+ details=details,
197
+ )
198
+ )
199
+ return
200
+ resolved_delay = DEFAULT_NACK_RELEASE_DELAY_SECONDS if delay_seconds is None else delay_seconds
201
+ await asyncio.to_thread(lambda: self._operations.release_item(transaction.id, delay_seconds=resolved_delay))
147
202
  return
148
203
 
149
204
  target_phase = self._consumer_config.nack_phase_id if self._consumer_config is not None else None
@@ -152,6 +207,39 @@ class AsyncErgonPlatformConnector(AsyncConnector):
152
207
 
153
208
  await asyncio.to_thread(lambda: self.client.workflows.items.route(transaction.id, to_phase_id=target_phase))
154
209
 
210
+ # -- Pull only outcomes ----------------------------------------------------
211
+
212
+ async def complete_transaction(self, transaction: Transaction, *, reason: Optional[str] = None) -> Any:
213
+ await self.is_pull_async()
214
+ return await asyncio.to_thread(lambda: self._operations.complete_item(transaction.id, reason=reason))
215
+
216
+ async def fail_transaction(
217
+ self,
218
+ transaction: Transaction,
219
+ *,
220
+ reason: str,
221
+ delay_seconds: Optional[int] = None,
222
+ details: Optional[str] = None,
223
+ ) -> Any:
224
+ await self.is_pull_async()
225
+ return await asyncio.to_thread(
226
+ lambda: self._operations.fail_item(
227
+ transaction.id, reason=reason, delay_seconds=delay_seconds, details=details
228
+ )
229
+ )
230
+
231
+ async def defer_transaction(
232
+ self, transaction: Transaction, *, delay_seconds: int, reason: Optional[str] = None
233
+ ) -> Any:
234
+ await self.is_pull_async()
235
+ return await asyncio.to_thread(
236
+ lambda: self._operations.defer_item(transaction.id, delay_seconds=delay_seconds, reason=reason)
237
+ )
238
+
239
+ async def extend_claim(self, transaction: Transaction, *, seconds: Optional[int] = None) -> Any:
240
+ await self.is_pull_async()
241
+ return await asyncio.to_thread(lambda: self._operations.extend_claim(transaction.id, seconds=seconds))
242
+
155
243
  async def close(self) -> None:
156
244
  await asyncio.to_thread(self.client.close)
157
245
 
@@ -5,6 +5,7 @@ from ...connector import Connector
5
5
  from ...transaction import Transaction
6
6
  from .._client import create_ergon_client
7
7
  from ..models import ErgonPlatformClient
8
+ from ._mode import DEFAULT_NACK_REASON, DEFAULT_NACK_RELEASE_DELAY_SECONDS, ConsumerModeState
8
9
  from ._operations import ErgonPlatformWorkflowsService
9
10
  from .models import (
10
11
  CreateItemPayload,
@@ -28,6 +29,20 @@ class ErgonPlatformConnector(Connector):
28
29
  self._operations = self.service
29
30
  self._consumer_config = consumer_config
30
31
  self._producer_config = producer_config or ErgonPlatformProducerConfig()
32
+ self._mode = ConsumerModeState(client, consumer_config, self.service, self._set_client)
33
+
34
+ def _set_client(self, client: Any) -> None:
35
+ self.client = client
36
+
37
+ @property
38
+ def is_pull(self) -> bool:
39
+ """True when the consumed phase is Pull only (resolved once, on first use)."""
40
+ return self._mode.is_pull()
41
+
42
+ @property
43
+ def consumer_id(self) -> Optional[str]:
44
+ """The ``X-Ergon-Consumer-Id`` this connector claims under; None on standard phases."""
45
+ return self._mode.consumer_id
31
46
 
32
47
  def fetch_transactions(
33
48
  self,
@@ -40,6 +55,17 @@ class ErgonPlatformConnector(Connector):
40
55
 
41
56
  config = self._consumer_config
42
57
  limit = batch_size or config.batch_size
58
+ if self.is_pull:
59
+ if kwargs:
60
+ logger.debug("Ignoring list params %s on Pull only phase %s", sorted(kwargs), config.phase_id)
61
+ return self._operations.pull_items(
62
+ config.workflow_id,
63
+ config.phase_id,
64
+ limit=limit,
65
+ filters=config.pull_filters,
66
+ wait_seconds=config.pull_wait_seconds,
67
+ )
68
+
43
69
  params: Dict[str, Any] = {**config.list_params, **kwargs}
44
70
  if config.unassigned:
45
71
  params.pop("assigned_to", None)
@@ -105,12 +131,15 @@ class ErgonPlatformConnector(Connector):
105
131
  )
106
132
 
107
133
  def ack_transaction(self, transaction: Transaction, phase_id: Optional[str] = None) -> None:
134
+ """Route to ``phase_id`` / ``ack_phase_id``; on a Pull only phase with no target, complete the stay."""
108
135
  target_phase = phase_id
109
136
  if target_phase is None and self._consumer_config is not None:
110
137
  target_phase = self._consumer_config.ack_phase_id
111
- if not target_phase:
138
+ if target_phase:
139
+ self.client.workflows.items.route(transaction.id, to_phase_id=target_phase)
112
140
  return
113
- self.client.workflows.items.route(transaction.id, to_phase_id=target_phase)
141
+ if self.is_pull:
142
+ self._operations.complete_item(transaction.id)
114
143
 
115
144
  def release_item(
116
145
  self,
@@ -120,6 +149,9 @@ class ErgonPlatformConnector(Connector):
120
149
  delay_seconds: Optional[int] = None,
121
150
  **fields: Any,
122
151
  ) -> Any:
152
+ """Give the item back. ``delay_seconds`` > 0 is a defer on Pull only phases."""
153
+ if self.is_pull and delay_seconds:
154
+ return self._operations.defer_item(item_id, delay_seconds=delay_seconds)
123
155
  return self._operations.release_item(
124
156
  item_id,
125
157
  data,
@@ -127,8 +159,33 @@ class ErgonPlatformConnector(Connector):
127
159
  **fields,
128
160
  )
129
161
 
130
- def nack_transaction(self, transaction: Transaction, requeue: bool = True, delay_seconds: int = 10) -> None:
162
+ def nack_transaction(
163
+ self,
164
+ transaction: Transaction,
165
+ requeue: bool = True,
166
+ delay_seconds: Optional[int] = None,
167
+ *,
168
+ reason: Optional[str] = None,
169
+ details: Optional[str] = None,
170
+ ) -> None:
171
+ """Hand the item back after a failure.
172
+
173
+ Pull only phase: ``fail`` (counts an attempt; the platform moves the item
174
+ to Exceptions at ``max_attempts``), retried after ``delay_seconds`` or the
175
+ phase retry policy. Standard phase: release with a ``delay_seconds``
176
+ cooldown (default 10 s). ``requeue=False`` routes to ``nack_phase_id`` on both.
177
+ """
131
178
  if requeue:
179
+ if self.is_pull:
180
+ self._operations.fail_item(
181
+ transaction.id,
182
+ reason=reason or DEFAULT_NACK_REASON,
183
+ delay_seconds=delay_seconds,
184
+ details=details,
185
+ )
186
+ return
187
+ if delay_seconds is None:
188
+ delay_seconds = DEFAULT_NACK_RELEASE_DELAY_SECONDS
132
189
  self._operations.release_item(transaction.id, delay_seconds=delay_seconds)
133
190
  return
134
191
 
@@ -139,6 +196,35 @@ class ErgonPlatformConnector(Connector):
139
196
  self.client.workflows.items.route(transaction.id, to_phase_id=target_phase)
140
197
  logger.debug("Item %s moved to nack phase %s", transaction.id, target_phase)
141
198
 
199
+ # -- Pull only outcomes ----------------------------------------------------
200
+
201
+ def complete_transaction(self, transaction: Transaction, *, reason: Optional[str] = None) -> Any:
202
+ """Mark the current stay done; the item stays in the phase and pull skips it."""
203
+ self.is_pull # resolve the mode so the call carries the consumer id
204
+ return self._operations.complete_item(transaction.id, reason=reason)
205
+
206
+ def fail_transaction(
207
+ self,
208
+ transaction: Transaction,
209
+ *,
210
+ reason: str,
211
+ delay_seconds: Optional[int] = None,
212
+ details: Optional[str] = None,
213
+ ) -> Any:
214
+ """Record a failed attempt; visible again after ``delay_seconds`` (or the phase retry policy)."""
215
+ self.is_pull
216
+ return self._operations.fail_item(transaction.id, reason=reason, delay_seconds=delay_seconds, details=details)
217
+
218
+ def defer_transaction(self, transaction: Transaction, *, delay_seconds: int, reason: Optional[str] = None) -> Any:
219
+ """Put the item back without counting an attempt; hidden for ``delay_seconds``."""
220
+ self.is_pull
221
+ return self._operations.defer_item(transaction.id, delay_seconds=delay_seconds, reason=reason)
222
+
223
+ def extend_claim(self, transaction: Transaction, *, seconds: Optional[int] = None) -> Any:
224
+ """Push the claim expiry out; call from long steps so the claim does not lapse."""
225
+ self.is_pull
226
+ return self._operations.extend_claim(transaction.id, seconds=seconds)
227
+
142
228
  def close(self) -> None:
143
229
  self.client.close()
144
230
 
@@ -1,7 +1,9 @@
1
- from typing import Any, Dict, Optional, Union
1
+ from typing import Any, Dict, List, Literal, Optional, Union
2
2
 
3
3
  from pydantic import BaseModel, Field
4
4
 
5
+ ConsumerMode = Literal["auto", "list_claim", "pull"]
6
+
5
7
  # Status buckets returned by the Ergon Platform attachment pipeline.
6
8
  SUCCESS_STATUSES = {"completed", "complete", "succeeded", "success", "processed", "ready"}
7
9
  FAILURE_STATUSES = {"failed", "error", "errored", "skipped", "oversized"}
@@ -41,6 +43,37 @@ class ErgonPlatformConsumerConfig(BaseModel):
41
43
  default_factory=dict,
42
44
  description="Extra query params forwarded to the items listing endpoint",
43
45
  )
46
+ mode: ConsumerMode = Field(
47
+ default="auto",
48
+ description=(
49
+ "How items are taken from the phase. 'list_claim' lists and claims one by one "
50
+ "(standard phases). 'pull' uses POST /phases/{id}/items/pull with a consumer id "
51
+ "(Pull only phases): atomic claim with expiry, fail/defer/complete outcomes, "
52
+ "attempt limit handled by the platform. 'auto' (default) reads the phase kind "
53
+ "once, on the first fetch, and picks the matching behaviour."
54
+ ),
55
+ )
56
+ pull_filters: Optional[List[Dict[str, Any]]] = Field(
57
+ default=None,
58
+ description=(
59
+ "Pull mode only. Field filters a pulled item must match, same shape as the "
60
+ "'filters' list of POST /items/query (e.g. [{'field_id': ..., 'op': 'eq', 'value': ...}])."
61
+ ),
62
+ )
63
+ pull_wait_seconds: Optional[int] = Field(
64
+ default=None,
65
+ ge=0,
66
+ le=30,
67
+ description="Pull mode only. Long-poll up to this many seconds when the phase has no visible item.",
68
+ )
69
+ consumer_id: Optional[str] = Field(
70
+ default=None,
71
+ description=(
72
+ "Pull mode only. Worker identity sent as X-Ergon-Consumer-Id; claims taken under it "
73
+ "expire and are fenced against other consumers. Defaults to '<hostname>:<pid>'. "
74
+ "Allowed: [A-Za-z0-9._:@-]{1,128}."
75
+ ),
76
+ )
44
77
 
45
78
 
46
79
  class ErgonPlatformProducerConfig(BaseModel):
@@ -340,6 +340,10 @@ class AsyncRabbitMQService:
340
340
  self._consumer_state = "connect_stalled"
341
341
  await self._reset_connection("consume channel creation timed out")
342
342
  raise
343
+ # async_timeout types __aexit__ as Optional[bool], so type checkers
344
+ # assume the block may swallow an exception and skip the assignment.
345
+ if self._consume_channel is None:
346
+ raise RuntimeError("RabbitMQ consume channel was not created")
343
347
  # aio-pika exposes close_callbacks on current releases; retain the
344
348
  # legacy add_close_callback fallback for older supported versions.
345
349
  close_callbacks = getattr(self._consume_channel, "close_callbacks", None)
@@ -374,6 +378,8 @@ class AsyncRabbitMQService:
374
378
  except asyncio.TimeoutError:
375
379
  await self._reset_connection("publish channel creation timed out")
376
380
  raise
381
+ if self._publish_channel is None:
382
+ raise RuntimeError("RabbitMQ publish channel was not created")
377
383
 
378
384
  return self._publish_channel
379
385
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Requires-Dist: aio-pika>=9.0.0; extra == "all"
29
29
  Requires-Dist: aiobotocore>=2.13.0; extra == "all"
30
30
  Requires-Dist: asyncpg>=0.29.0; extra == "all"
31
31
  Requires-Dist: boto3>=1.35.0; extra == "all"
32
- Requires-Dist: ergon-platform-sdk>=0.2.0; extra == "all"
32
+ Requires-Dist: ergon-platform-sdk<0.6,>=0.5.1; extra == "all"
33
33
  Requires-Dist: httpx>=0.28.0; extra == "all"
34
34
  Requires-Dist: msal>=1.37.0; extra == "all"
35
35
  Requires-Dist: nylas>=6.0.0; extra == "all"
@@ -37,7 +37,7 @@ Requires-Dist: openpyxl>=3.1.5; extra == "all"
37
37
  Requires-Dist: pika>=1.3.0; extra == "all"
38
38
  Requires-Dist: requests>=2.32.5; extra == "all"
39
39
  Provides-Extra: ergon-platform
40
- Requires-Dist: ergon-platform-sdk>=0.2.0; extra == "ergon-platform"
40
+ Requires-Dist: ergon-platform-sdk<0.6,>=0.5.1; extra == "ergon-platform"
41
41
  Requires-Dist: httpx>=0.28.0; extra == "ergon-platform"
42
42
  Provides-Extra: excel
43
43
  Requires-Dist: openpyxl>=3.1.5; extra == "excel"
@@ -1,4 +1,4 @@
1
- ergon/__init__.py,sha256=YxjNiBz68BV9DIN53Yy5zCKMVcca7VJk8A_8_Z184M4,218
1
+ ergon/__init__.py,sha256=LI3pDbBIuX3yMUrCAeP5z6Kw96CA5xpjWKcYhSzA8Y4,218
2
2
  ergon/cli.py,sha256=CF8PvOjIguR18a657aEBEyhFXUoSVtSRVXv5PfnuWtA,6021
3
3
  ergon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  ergon/bootstrap/src/__project__/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -27,7 +27,7 @@ ergon/connector/_lazy.py,sha256=ANsKY3JTtmVo8Z8p3ey3LwkQGVVYITKO1eEK66IvA3U,1690
27
27
  ergon/connector/connector.py,sha256=jaF1iXKALE1IHHfcP54mTD5mvj-wzk5iNaLo96IOcDI,3944
28
28
  ergon/connector/transaction.py,sha256=LMGEFfisdjGGCPrWps3nYJ-SRBKa-P2pOFmwbSgDN-E,291
29
29
  ergon/connector/ergon_platform/__init__.py,sha256=BYp8roDI5JyKdwLaVGeE9Qnx3VWt4gpPdAdb258YKuw,3807
30
- ergon/connector/ergon_platform/_client.py,sha256=RQbTRanK0pF_JzJi7EF9_6d4veCC4_xfXHk5A0nL5jE,812
30
+ ergon/connector/ergon_platform/_client.py,sha256=HI-qX2Mt99H6yLjC1F0opaul-xksKR2a178QtCMYPnw,1776
31
31
  ergon/connector/ergon_platform/async_connector.py,sha256=Is4wpMO9dtPNJoj1Y8WeyZg8bpskHbcpApdVYl45OYM,184
32
32
  ergon/connector/ergon_platform/connector.py,sha256=pIRBPoWTLs8q_5_2Dby7fNEMQJWsnVOrt7dTJdFZdMo,162
33
33
  ergon/connector/ergon_platform/models.py,sha256=ydrowIUaMhiZ08iJPKynkJU19RvqmogIreQFeJKhCuw,1248
@@ -45,10 +45,11 @@ ergon/connector/ergon_platform/channels/services/messages.py,sha256=us0gemfUAk3G
45
45
  ergon/connector/ergon_platform/channels/services/records.py,sha256=yv_xl0lFhksk0baHpHkFlqA8X3YaoEOQ9jpOUT5guk0,2483
46
46
  ergon/connector/ergon_platform/channels/services/service.py,sha256=BjqRYeLUZtzXpPeOLjgNdlq7TJXFc28SJtMH-Uha2DY,1160
47
47
  ergon/connector/ergon_platform/workflows/__init__.py,sha256=W5TDhYmldZqfQRJ4G8siFis7AAasIAuZhtqrwlR5QFk,1315
48
- ergon/connector/ergon_platform/workflows/_operations.py,sha256=IJPsiCEo27UvRKhGTrxn1HqCxbaPfVLwV0mFGgs0sOw,9750
49
- ergon/connector/ergon_platform/workflows/async_connector.py,sha256=PYqLxDAw1M0T6dR9SckohT1nhibPyRoBhdu9JFJ2r90,8463
50
- ergon/connector/ergon_platform/workflows/connector.py,sha256=gooEFbkzVB0ig-a29sD__hZJeNEQ9JfDi2mxBuLQGB0,7576
51
- ergon/connector/ergon_platform/workflows/models.py,sha256=_a2qH9Bkx5f2IqWb0LdqML0D088aH7AwApe204REoEE,3591
48
+ ergon/connector/ergon_platform/workflows/_mode.py,sha256=ycgCprhZrJzLH67nxkM0hWIM85_0pmiOpXbHSd50gso,3309
49
+ ergon/connector/ergon_platform/workflows/_operations.py,sha256=bOLc1XS3XGzp1OswwNPy6dViRigSyMdOI_SVnnFmJV8,13377
50
+ ergon/connector/ergon_platform/workflows/async_connector.py,sha256=wrzDri3F04BD_-W-aoDNZly1XIsQrNhygELjBv9-Dng,12341
51
+ ergon/connector/ergon_platform/workflows/connector.py,sha256=Y0Vl1EEfcyydanEK8hDuDV6DGdHw5en2Aua4YKQX6QI,11466
52
+ ergon/connector/ergon_platform/workflows/models.py,sha256=EsYilk19iqhy1cpOTtBx120lvXufMgGFH-sTWZFGchI,5063
52
53
  ergon/connector/ergon_platform/workflows/utils.py,sha256=---WoYGjyAqKLl-OWOqpkvFDK8HCeiApmz47nJ3z958,8779
53
54
  ergon/connector/excel/__init__.py,sha256=YHrcMRwW96DtvRs2CNpKK5GW3lJXZSdClLUWCDMgzsA,1013
54
55
  ergon/connector/excel/connector.py,sha256=Tx2T9Ggo8pPAaSmua4j_PMu_QiiiezWm1Nru3Jr0E94,5327
@@ -83,7 +84,7 @@ ergon/connector/postgres/async_service.py,sha256=PdPnNElMKzLoov1as3Kcb_viuSyBaVE
83
84
  ergon/connector/postgres/models.py,sha256=DL9wBdZ6QZPH1iVft71cTabnFUCH1Hg1RmG5f5E8578,1749
84
85
  ergon/connector/rabbitmq/__init__.py,sha256=R0VuHRuKRbshm7drw7p3vi2Az6e8PjIsvT-JTtRwxxo,1478
85
86
  ergon/connector/rabbitmq/async_connector.py,sha256=1ltA8wGcB91Sxq6xJcX0OeOTRd6co4cpq5drG-6gHAc,5175
86
- ergon/connector/rabbitmq/async_service.py,sha256=1nY-Bt9qEno1H4ycB31nG3USUFBoKjOcu6BRt8VEVLM,35157
87
+ ergon/connector/rabbitmq/async_service.py,sha256=_J__aNcrxyKelqHJ4Hv0SsgDZcc71NPYgbXd498g71k,35568
87
88
  ergon/connector/rabbitmq/connector.py,sha256=vsSzbVuuE8rKkwBfPgD97qTsqADxJGALnUvrEwZIL5Q,1633
88
89
  ergon/connector/rabbitmq/helper.py,sha256=xlQGx2SGR2DC1fi-5BMlCOvWydWEFkcN7fPJjlNvGLA,372
89
90
  ergon/connector/rabbitmq/models.py,sha256=Q8tFPdRuvSxVe70HlTBRY66DdrC7kXuSCbRjtPSSq6k,9910
@@ -116,9 +117,9 @@ ergon/telemetry/metrics.py,sha256=0T_CuNw9P5AG1U9k1-bNp1g7McyiNLUIt_zKyLOtrnA,28
116
117
  ergon/telemetry/tracing.py,sha256=_ktiZWAgB1DU-uN91Mzr0iYNXxbCN21LnKiRo5SOJxY,4454
117
118
  ergon/utils/__init__.py,sha256=M0_VCjMN7jvSPlRZLZHeFKTHAl1zu3BnAhjrzJjDfiI,57
118
119
  ergon/utils/env.py,sha256=lji4xENvfHqg2GTGnzG95VKpxxM4f3DHIKKP61xQG08,621
119
- ergon_framework_python-0.2.4.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
120
- ergon_framework_python-0.2.4.dist-info/METADATA,sha256=Mt-c5h17mfvb-JJDgqNfQKXg2zeIYBBxUh9XrsKrt2A,18371
121
- ergon_framework_python-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
122
- ergon_framework_python-0.2.4.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
123
- ergon_framework_python-0.2.4.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
124
- ergon_framework_python-0.2.4.dist-info/RECORD,,
120
+ ergon_framework_python-0.3.0.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
121
+ ergon_framework_python-0.3.0.dist-info/METADATA,sha256=4jpSXaxJHbORTHYo64JiURQUqS2GcseCnLz9xuw8T8A,18381
122
+ ergon_framework_python-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
123
+ ergon_framework_python-0.3.0.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
124
+ ergon_framework_python-0.3.0.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
125
+ ergon_framework_python-0.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5