pl8-base 0.0.1__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.
pl8_base/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: MIT
pl8_base/const.py ADDED
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ MAX_ISSUE_ID_LEN = 16
4
+ MIN_ISSUE_ID_LEN = 3
5
+ RETRY_ISSUE_ID_COLLISIONS = 5
6
+
7
+ # Bounded because space_id is caller-supplied and composes both SPACE#{space_id}
8
+ # and ISSUE#{space_id}#{issue_id}. 64 leaves the composite key far inside
9
+ # DynamoDB's 2048 byte limit.
10
+ MAX_SPACE_ID_LEN = 64
11
+
12
+ # Full-jitter exponential backoff for TransactionConflict retries
13
+ TRANSACT_RETRY_ATTEMPTS = 5
14
+ TRANSACT_RETRY_BASE_DELAY = 0.05
15
+ TRANSACT_RETRY_MAX_DELAY = 1.0
16
+
17
+ # CancellationReasons code marking a transient transaction conflict
18
+ TRANSACT_CONFLICT_REASON = "TransactionConflict"
19
+ # CancellationReasons code marking a failed ConditionExpression
20
+ CONDITION_FAILED_REASON = "ConditionalCheckFailed"
21
+ # Error code a failed ConditionExpression raises outside a transaction
22
+ CONDITION_FAILED_CODE = "ConditionalCheckFailedException"
23
+
24
+ # Name of the single GSI on the base table
25
+ GSI1_INDEX_NAME = "GSI1"
pl8_base/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ class DDBError(Exception):
4
+ """Base class for DDB Issues"""
5
+
6
+
7
+ class DDBInternalError(DDBError):
8
+ """Raised for general service errors"""
9
+
10
+
11
+ class DDBCorruptedError(DDBInternalError):
12
+ """Raised if data read from database is corrupted"""
13
+
14
+
15
+ class DDBMissingError(DDBError):
16
+ """Raised when a requested item does not exist"""
17
+
18
+
19
+ class DDBExistsError(DDBError):
20
+ """Raised when an item already exists"""
21
+
22
+
23
+ class DDBArgsError(DDBError):
24
+ """Raised on invalid arguments passed in"""
25
+
26
+
27
+ class DDBIdCollisionError(DDBError):
28
+ """Raised on ID collision after too many retries"""
29
+
30
+
31
+ class DDBTransactionConflictError(DDBError):
32
+ """Raised on transaction conflict.
33
+
34
+ Transient contention. The same request may be retried unchanged; see
35
+ util.retry_on_transaction_conflict.
36
+ """
37
+
38
+
39
+ class DDBVersionConflictError(DDBError):
40
+ """Raised when a write's version condition fails.
41
+
42
+ The caller's view of the item is stale, so retrying the same request will
43
+ fail again. The caller must re-read and reapply its change.
44
+ """
45
+
46
+
47
+ class DDBTerminalStatusError(DDBError):
48
+ """Raised when an operation would move an Issue out of DONE, or would
49
+ mutate an Issue whose DONE status forbids the change.
50
+
51
+ DONE is terminal; see types.enums.IssueStatus for why that rule is
52
+ load-bearing beyond the product requirement.
53
+ """
54
+
55
+
56
+ class DDBStillBlockedError(DDBError):
57
+ """Raised when an Issue would be transitioned out of BLOCKED while it
58
+ still has active IssueBlockers.
59
+
60
+ The caller must delete the remaining IssueBlockers first, or wait for the
61
+ blocking Issues to reach DONE.
62
+ """
63
+
64
+
65
+ class DDBBlockingIssueDoneError(DDBError):
66
+ """Raised when an IssueBlocker would name a DONE Issue as the blocker.
67
+
68
+ A DONE Issue blocks nothing; the relationship would be created already
69
+ satisfied.
70
+ """
71
+
72
+
73
+ class EventError(Exception):
74
+ """Base class for event issues"""
75
+
76
+
77
+ class EventSendError(EventError):
78
+ """Raised when EventBridge rejects or fails to accept an event.
79
+
80
+ Covers both a failed put_events call and a per-entry failure reported
81
+ back with FailedEntryCount > 0.
82
+ """
83
+
84
+
85
+ class EventCorruptedError(EventError):
86
+ """Raised when an event's Detail is missing/unknown type, or malformed"""
pl8_base/manager.py ADDED
@@ -0,0 +1,413 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ import msgspec
4
+ from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
5
+ from botocore.exceptions import ClientError
6
+
7
+ from .const import (
8
+ CONDITION_FAILED_CODE,
9
+ CONDITION_FAILED_REASON,
10
+ TRANSACT_CONFLICT_REASON,
11
+ )
12
+ from .errors import (
13
+ DDBCorruptedError,
14
+ DDBInternalError,
15
+ DDBMissingError,
16
+ DDBTransactionConflictError,
17
+ DDBVersionConflictError,
18
+ )
19
+ from .mixins import IssueMixin, SpaceMixin
20
+ from .types import CLASS_MAP
21
+ from .util import (
22
+ decode_pagination_cursor,
23
+ encode_pagination_cursor,
24
+ isotime,
25
+ )
26
+
27
+
28
+ class BasePL8(IssueMixin, SpaceMixin):
29
+ def __init__(self, *, dynamodb_client, table_name, logger):
30
+ """Init manager.
31
+ Args:
32
+ dynamodb_client (boto3.dynamodb): DynamoDB client
33
+ table_name (str): DynamoDB table name to use
34
+ logger (aws_lambda_powertools.Logger): injected structured
35
+ logger. Extra keyword args are merged into the emitted
36
+ JSON log record.
37
+ """
38
+ self.dynamodb_client = dynamodb_client
39
+ self.table_name = table_name
40
+ self.logger = logger
41
+ self.ts = TypeSerializer()
42
+ self.td = TypeDeserializer()
43
+
44
+
45
+ def log_client_error(self, exc):
46
+ """Util method for structured AWS error logging
47
+
48
+ Args:
49
+ exc (ClientError): exception
50
+ """
51
+ error_code = exc.response["Error"]["Code"]
52
+ self.logger.exception(f"ClientError (code: {error_code})",
53
+ response=exc.response)
54
+
55
+ def parse_item(self, item):
56
+ """Util method for parsing a raw dynamodb item into a dataclass.
57
+
58
+ Logs error details if unable to parse.
59
+
60
+ Args:
61
+ item (dict or None): raw dynamodb item
62
+
63
+ Returns:
64
+ BaseObject or None: parsed object, or None if unable to parse
65
+
66
+ Raises:
67
+ DDBCorruptedError: if missing or unknown type, or malformed item
68
+ """
69
+ if item is None:
70
+ return
71
+ item_type = item.get("type")
72
+ if item_type is None:
73
+ self.logger.error("Malformed item without type", item=item)
74
+ raise DDBCorruptedError("Malformed item without type")
75
+
76
+ item_type = self.td.deserialize(item_type)
77
+ type_class = CLASS_MAP.get(item_type)
78
+
79
+ if type_class is None:
80
+ self.logger.error("Retrieved item with unknown type",
81
+ item_type=item_type, item=item)
82
+ raise DDBCorruptedError(f"Item with unknown type: {item_type}")
83
+
84
+ try:
85
+ parsed = type_class.from_item(item, td=self.td)
86
+ except Exception as exc:
87
+ self.logger.exception("Malformed item", item_type=item_type,
88
+ item=item)
89
+ raise DDBCorruptedError(f"Malformed item: {exc!s}")
90
+
91
+ return parsed
92
+
93
+ def serialize_value(self, value):
94
+ """Serialize one Python value to a DynamoDB AttributeValue.
95
+
96
+ Goes through msgspec first so enums arrive as their bare values; a
97
+ StrEnum would otherwise be serialized as itself and leak the member
98
+ repr into anything that does not compare it as a string.
99
+
100
+ Bytes skip that step. msgspec encodes them as a base64 *string*, which
101
+ would quietly store a compressed attr as S instead of B and break the
102
+ round-trip back through BaseObject.from_item.
103
+
104
+ Args:
105
+ value: any msgspec-encodable value
106
+
107
+ Returns:
108
+ dict: DynamoDB AttributeValue
109
+ """
110
+ if isinstance(value, (bytes, bytearray)):
111
+ return self.ts.serialize(value)
112
+
113
+ return self.ts.serialize(msgspec.to_builtins(value))
114
+
115
+ def old_item_from_exc(self, exc):
116
+ """Parse the pre-write item a failed condition returned, if any.
117
+
118
+ Requires the write to have set ReturnValuesOnConditionCheckFailure to
119
+ ALL_OLD. A missing item means the row did not exist, which is how a
120
+ DDBMissingError is told apart from a condition that genuinely failed.
121
+
122
+ Args:
123
+ exc (ClientError): a ConditionalCheckFailedException
124
+
125
+ Returns:
126
+ BaseObject or None: the item as it was, or None if absent
127
+ """
128
+ return self.parse_item(exc.response.get("Item"))
129
+
130
+ def cancellation_reasons(self, exc):
131
+ """Per-item cancellation reasons from a cancelled transaction.
132
+
133
+ Reasons come back positionally, aligned with TransactItems, so callers
134
+ must keep their item ordering stable to read these.
135
+
136
+ Args:
137
+ exc (ClientError): a TransactionCanceledException
138
+
139
+ Returns:
140
+ list[dict]: one reason per transaction item
141
+ """
142
+ return exc.response.get("CancellationReasons") or []
143
+
144
+ def raise_for_transaction_conflict(self, exc):
145
+ """Re-raise a cancelled transaction as DDBTransactionConflictError.
146
+
147
+ Contention is transient and the same request may be retried unchanged;
148
+ see util.retry_on_transaction_conflict. Returns without raising if the
149
+ cancellation was for some other reason.
150
+
151
+ Args:
152
+ exc (ClientError): a TransactionCanceledException
153
+
154
+ Raises:
155
+ DDBTransactionConflictError: if any item reports a conflict
156
+ """
157
+ for reason in self.cancellation_reasons(exc):
158
+ if reason.get("Code") == TRANSACT_CONFLICT_REASON:
159
+ raise DDBTransactionConflictError(
160
+ "Transaction conflict") from exc
161
+
162
+ def get_primary_item(self, *, PK, SK):
163
+ """Load one item by its primary key.
164
+
165
+ Args:
166
+ PK (str): partition key
167
+ SK (str): sort key
168
+
169
+ Returns:
170
+ BaseObject: the parsed item
171
+
172
+ Raises:
173
+ DDBMissingError: if the item does not exist
174
+ DDBCorruptedError: if the item cannot be parsed
175
+ DDBInternalError: internal database error
176
+ """
177
+ try:
178
+ resp = self.dynamodb_client.get_item(
179
+ TableName=self.table_name,
180
+ Key={
181
+ "PK": self.ts.serialize(PK),
182
+ "SK": self.ts.serialize(SK),
183
+ },
184
+ )
185
+ except ClientError as exc:
186
+ self.log_client_error(exc)
187
+ raise DDBInternalError(f"Error loading item: {exc!s}") from exc
188
+
189
+ item = resp.get("Item")
190
+ if item is None:
191
+ raise DDBMissingError(f"Item not found: PK={PK} SK={SK}")
192
+
193
+ return self.parse_item(item)
194
+
195
+ def _build_update(self, *, PK, SK, version=None, expected_vals=None,
196
+ excluded_vals=None, increments=None, **attrs):
197
+ """Build an update dict for update_item or transact_write_items.
198
+
199
+ Every write bumps version and sets updated_at. The version *condition*
200
+ is opt-in, applied only when the caller passes version=.
201
+
202
+ Consumer-facing operations pass version= to fence a caller whose view of
203
+ the item is stale, and get DDBVersionConflictError back if it moved.
204
+ Nearly every background write to an Issue is semantically meaningful to a
205
+ consumer (num_active_blockers, or the status flip that follows it), so
206
+ these are not spurious conflicts.
207
+
208
+ The handle_* event consumers MUST NOT pass version=. They hold no
209
+ consumer's read, and their correctness comes from domain conditions
210
+ instead (is_blocking_issue_done=False, status=BLOCKED,
211
+ num_active_blockers=0), which are also what makes them idempotent under
212
+ at-least-once, unordered delivery. Fencing them on version would make
213
+ event replays fail spuriously.
214
+
215
+ Conditions are only built here; nothing is raised. The conditions fail
216
+ at write time as a single ConditionalCheckFailedException, which the
217
+ caller issuing the write is responsible for mapping onto
218
+ DDBMissingError, DDBVersionConflictError or a domain error.
219
+
220
+ Args:
221
+ PK (str): partition key of the item to update
222
+ SK (str): sort key of the item to update
223
+ version (int or None): if set, condition the write on this version
224
+ expected_vals (dict or None): attr values that must match
225
+ excluded_vals (dict or None): attr values that must NOT match.
226
+ Needed for the DONE checks, since "is terminal" is a rule about
227
+ what an attr must not be and expected_vals only compares equal.
228
+ increments (dict or None): attrs to add to, rather than overwrite.
229
+ Applied by DynamoDB, so a counter stays correct under
230
+ concurrent writers where a read-modify-write would not.
231
+ **attrs: attrs to set
232
+
233
+ Returns:
234
+ dict: update dict for update_item, or to wrap in {"Update": <dict>}
235
+ for transact_write_items
236
+ """
237
+ # Every attr goes through ExpressionAttributeNames: "status" and
238
+ # "version" are both DynamoDB reserved words, and the rest would be a
239
+ # trap waiting for the next field added.
240
+ expr_attr_names = {"#PK": "PK", "#version": "version"}
241
+ expr_attr_vals = {":version_incr": self.ts.serialize(1)}
242
+ set_clauses = ["#version = #version + :version_incr"]
243
+
244
+ for attr, value in {"updated_at": isotime(), **attrs}.items():
245
+ expr_attr_names[f"#{attr}"] = attr
246
+ expr_attr_vals[f":set_{attr}"] = self.serialize_value(value)
247
+ set_clauses.append(f"#{attr} = :set_{attr}")
248
+
249
+ for attr, delta in (increments or {}).items():
250
+ expr_attr_names[f"#{attr}"] = attr
251
+ expr_attr_vals[f":incr_{attr}"] = self.serialize_value(delta)
252
+ set_clauses.append(f"#{attr} = #{attr} + :incr_{attr}")
253
+
254
+ condition_clauses = ["attribute_exists(#PK)"]
255
+
256
+ if version is not None:
257
+ expr_attr_vals[":expected_version"] = self.serialize_value(version)
258
+ condition_clauses.append("#version = :expected_version")
259
+
260
+ for attr, value in (expected_vals or {}).items():
261
+ expr_attr_names[f"#{attr}"] = attr
262
+ expr_attr_vals[f":expected_{attr}"] = self.serialize_value(value)
263
+ condition_clauses.append(f"#{attr} = :expected_{attr}")
264
+
265
+ for attr, value in (excluded_vals or {}).items():
266
+ expr_attr_names[f"#{attr}"] = attr
267
+ expr_attr_vals[f":excluded_{attr}"] = self.serialize_value(value)
268
+ condition_clauses.append(f"#{attr} <> :excluded_{attr}")
269
+
270
+ return {
271
+ "TableName": self.table_name,
272
+ "Key": {
273
+ "PK": self.ts.serialize(PK),
274
+ "SK": self.ts.serialize(SK),
275
+ },
276
+ "UpdateExpression": "SET " + ", ".join(set_clauses),
277
+ "ConditionExpression": " AND ".join(condition_clauses),
278
+ "ExpressionAttributeNames": expr_attr_names,
279
+ "ExpressionAttributeValues": expr_attr_vals,
280
+ # Missing, stale version and an expected_vals mismatch all surface
281
+ # as one ConditionalCheckFailedException; the old item is the only
282
+ # way to tell them apart. In a transaction the per-item reasons come
283
+ # back positionally in CancellationReasons, so item ordering must
284
+ # stay stable.
285
+ "ReturnValuesOnConditionCheckFailure": "ALL_OLD",
286
+ }
287
+
288
+ def is_condition_failure(self, exc):
289
+ """Whether a ClientError is a failed ConditionExpression.
290
+
291
+ The non-transactional counterpart to raise_for_transaction_conflict:
292
+ outside a transaction a failed condition arrives as its own error code
293
+ rather than in CancellationReasons.
294
+
295
+ Args:
296
+ exc (ClientError): exception
297
+
298
+ Returns:
299
+ bool: True if the write failed its condition
300
+ """
301
+ return exc.response["Error"]["Code"] == CONDITION_FAILED_CODE
302
+
303
+ def failed_reason_item(self, exc, index):
304
+ """The old item for one cancelled transaction entry, if it failed.
305
+
306
+ Args:
307
+ exc (ClientError): a TransactionCanceledException
308
+ index (int): position of the entry in TransactItems
309
+
310
+ Returns:
311
+ tuple: (failed, item) where failed is whether that entry's
312
+ condition failed and item is the parsed pre-write item, which
313
+ is None when the row did not exist
314
+ """
315
+ reasons = self.cancellation_reasons(exc)
316
+ reason = reasons[index] if index < len(reasons) else {}
317
+
318
+ if reason.get("Code") != CONDITION_FAILED_REASON:
319
+ return False, None
320
+
321
+ return True, self.parse_item(reason.get("Item"))
322
+
323
+ def paginate(self, query, **kwargs):
324
+ """Yield every item from a cursor-based query on this manager."""
325
+ cursor = None
326
+ while True:
327
+ page, cursor = query(cursor=cursor, **kwargs)
328
+ yield from page
329
+ if cursor is None:
330
+ return
331
+
332
+ def run_query(self, params, cursor=None, limit=None):
333
+ """Run a query and return one page plus a continuation cursor.
334
+
335
+ Returns:
336
+ tuple: (list[BaseObject], str or None)
337
+ """
338
+ params = dict(params, TableName=self.table_name)
339
+ if limit is not None:
340
+ params["Limit"] = limit
341
+ if cursor is not None:
342
+ params["ExclusiveStartKey"] = decode_pagination_cursor(cursor)
343
+
344
+ try:
345
+ resp = self.dynamodb_client.query(**params)
346
+ except ClientError as exc:
347
+ self.log_client_error(exc)
348
+ raise DDBInternalError(f"Error running query: {exc!s}") from exc
349
+
350
+ items = [self.parse_item(item) for item in resp.get("Items", [])]
351
+ last_evaluated_key = resp.get("LastEvaluatedKey")
352
+ next_cursor = (encode_pagination_cursor(last_evaluated_key)
353
+ if last_evaluated_key else None)
354
+
355
+ return items, next_cursor
356
+
357
+ def apply_update(self, update, *, entity, ref, version=None, classify=None,
358
+ log_context=None):
359
+ """Apply a built update and return the item as written.
360
+
361
+ Resolves the two failures every conditional write shares, the row being
362
+ gone and the caller's version being stale, and leaves whatever domain
363
+ condition the caller added to its own classify hook. Entity-agnostic so
364
+ each entity shares those semantics by construction rather than by copy.
365
+
366
+ Args:
367
+ update (dict): an update dict from _build_update
368
+ entity (str): entity name for messages, e.g. "Issue"
369
+ ref (str): identifies the item in error messages
370
+ version (int or None): version the write was conditioned on
371
+ classify (callable or None): called with the pre-write item when a
372
+ domain condition failed, to raise the matching error
373
+ log_context (dict or None): structured fields to log when a
374
+ condition failure cannot be classified
375
+
376
+ Returns:
377
+ BaseObject: the item after the write
378
+
379
+ Raises:
380
+ DDBMissingError: if the item does not exist
381
+ DDBVersionConflictError: if version is set and did not match
382
+ DDBInternalError: internal database error
383
+ """
384
+ noun = entity.lower()
385
+
386
+ try:
387
+ resp = self.dynamodb_client.update_item(**update,
388
+ ReturnValues="ALL_NEW")
389
+ except ClientError as exc:
390
+ if not self.is_condition_failure(exc):
391
+ self.log_client_error(exc)
392
+ raise DDBInternalError(
393
+ f"Error updating {noun}: {exc!s}") from exc
394
+
395
+ old = self.old_item_from_exc(exc)
396
+
397
+ if old is None:
398
+ raise DDBMissingError(f"{entity} not found: {ref}") from exc
399
+
400
+ if version is not None and old.version != version:
401
+ raise DDBVersionConflictError(
402
+ f"{entity} {ref} changed since it was read") from exc
403
+
404
+ if classify is not None:
405
+ classify(old)
406
+
407
+ self.logger.error(
408
+ f"Unclassified condition failure updating {noun}",
409
+ **(log_context or {}))
410
+ raise DDBInternalError(
411
+ f"Error updating {noun}: {ref}") from exc
412
+
413
+ return self.parse_item(resp["Attributes"])
@@ -0,0 +1,9 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from .issue import IssueMixin
4
+ from .space import SpaceMixin
5
+
6
+ __all__ = [
7
+ "IssueMixin",
8
+ "SpaceMixin",
9
+ ]