rename key

This commit is contained in:
2025-05-23 13:46:20 -03:00
parent a7ee787378
commit 693ced6fdd
5 changed files with 135 additions and 70 deletions

View File

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
import pytz import pytz
TZ = os.getenv('TZ', 'UTC') TZ = os.getenv('TZ', 'UTC')

View File

@@ -135,8 +135,8 @@ if TYPE_CHECKING:
@dataclass @dataclass
class SortKey(str): class SortKey(str):
""" """
SortKey encapsulates the sort key value and optionally stores additional attributes SortKey encapsulates the sort key value and optionally stores additional
for nested data extraction. attributes for nested data extraction.
Parameters Parameters
---------- ----------
@@ -146,21 +146,18 @@ if TYPE_CHECKING:
Optional specification for nested data extraction. Optional specification for nested data extraction.
remove_prefix: str, optional remove_prefix: str, optional
Optional prefix to remove from the key when forming the result dict. Optional prefix to remove from the key when forming the result dict.
retain_key: bool, optional
Use the key itself as value if True; otherwise, use the extracted value.
""" """
sk: str sk: str
path_spec: str | None = None path_spec: str | None = None
remove_prefix: str | None = None remove_prefix: str | None = None
retain_key: bool = False
else: else:
class SortKey(str): class SortKey(str):
""" """
SortKey encapsulates the sort key value and optionally stores additional attributes SortKey encapsulates the sort key value and optionally stores additional
for nested data extraction. attributes for nested data extraction.
Parameters Parameters
---------- ----------
@@ -170,8 +167,6 @@ else:
Optional specification for nested data extraction. Optional specification for nested data extraction.
remove_prefix: str, optional remove_prefix: str, optional
Optional prefix to remove from the key when forming the result dict. Optional prefix to remove from the key when forming the result dict.
retain_key: bool, optional
Use the key itself as value if True; otherwise, use the extracted value.
""" """
def __new__( def __new__(
@@ -180,7 +175,6 @@ else:
*, *,
path_spec: str | None = None, path_spec: str | None = None,
remove_prefix: str | None = None, remove_prefix: str | None = None,
retain_key: bool = False,
) -> str: ) -> str:
return super().__new__(cls, sk) return super().__new__(cls, sk)
@@ -190,14 +184,12 @@ else:
*, *,
path_spec: str | None = None, path_spec: str | None = None,
remove_prefix: str | None = None, remove_prefix: str | None = None,
retain_key: bool = False,
) -> None: ) -> None:
# __init__ is used to store the parameters for later reference. # __init__ is used to store the parameters for later reference.
# For immutable types like str, __init__ cannot change the instance's value. # For immutable types like str, __init__ cannot change the instance's value.
self.sk = sk self.sk = sk
self.path_spec = path_spec self.path_spec = path_spec
self.remove_prefix = remove_prefix self.remove_prefix = remove_prefix
self.retain_key = retain_key
class Key(ABC, dict): class Key(ABC, dict):
@@ -228,11 +220,43 @@ class PartitionKey(Key):
class KeyPair(Key): class KeyPair(Key):
"""Represents a composite key (partition key and sort key) for DynamoDB queries""" """Represents a composite key (partition key and sort key) for DynamoDB queries"""
def __init__(self, pk: str, sk: str) -> None: def __init__(
self,
pk: str,
sk: str,
*,
rename_key: str | None = None,
retain_key: bool = False,
) -> None:
"""
Initializes a composite key using partition and sort key.
Parameters
----------
pk : str
The partition key.
sk : str
The sort key.
rename_key : str, optional
If provided, renames the sort key in the output.
retain_key : bool, optional
Use the key itself as value if True; otherwise, use the extracted value.
"""
super().__init__(**{PK: pk, SK: sk}) super().__init__(**{PK: pk, SK: sk})
self._rename_key = rename_key
self._retain_key = retain_key
@property
def rename_key(self) -> str | None:
return self._rename_key
@property
def retain_key(self) -> bool:
return self._retain_key
def __repr__(self) -> str: def __repr__(self) -> str:
pk, sk = self.values() pk, sk, *_ = self.values()
return f'KeyPair({pk!r}, {sk!r})' return f'KeyPair({pk!r}, {sk!r})'
def expr_attr_name(self) -> dict: def expr_attr_name(self) -> dict:
@@ -517,15 +541,19 @@ class DynamoDBPersistenceLayer:
limit: int | None = None, limit: int | None = None,
index_forward: bool = True, index_forward: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""You must provide the name of the partition key attribute and a single value for that attribute. """You must provide the name of the partition key attribute
and a single value for that attribute.
Query returns all items with that partition key value. Query returns all items with that partition key value.
Optionally, you can provide a sort key attribute and use a comparison operator to refine the search results. Optionally, you can provide a sort key attribute and use a comparison operator
to refine the search results.
... ...
A `Query` operation always returns a result set. If no matching items are found, the result set will be empty. A `Query` operation always returns a result set. If no matching items are found,
Queries that do not return results consume the minimum number of read capacity units for that type of read operation. the result set will be empty.
Queries that do not return results consume the minimum number
of read capacity units for that type of read operation.
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/query.html
""" """
@@ -563,8 +591,11 @@ class DynamoDBPersistenceLayer:
) )
def get_item(self, key: dict) -> dict: def get_item(self, key: dict) -> dict:
"""The GetItem operation returns a set of attributes for the item with the given primary key. """The GetItem operation returns a set of attributes for the item
If there is no matching item, GetItem does not return any data and there will be no Item element in the response. with the given primary key.
If there is no matching item, GetItem does not return any data and
there will be no Item element in the response.
""" """
attrs = { attrs = {
'TableName': self.table_name, 'TableName': self.table_name,
@@ -639,8 +670,9 @@ class DynamoDBPersistenceLayer:
expr_attr_names: dict | None = None, expr_attr_names: dict | None = None,
expr_attr_values: dict | None = None, expr_attr_values: dict | None = None,
) -> bool: ) -> bool:
"""Deletes a single item in a table by primary key. You can perform a conditional delete operation that deletes """Deletes a single item in a table by primary key. You can perform
the item if it exists, or if it has an expected attribute value. a conditional delete operation that deletes the item if it exists,
or if it has an expected attribute value.
""" """
attrs: dict = { attrs: dict = {
'TableName': self.table_name, 'TableName': self.table_name,
@@ -722,15 +754,18 @@ class PaginatedResult(TypedDict):
class DynamoDBCollection: class DynamoDBCollection:
""" """
DynamoDBCollection provides a high-level abstraction for performing common CRUD operations DynamoDBCollection provides a high-level abstraction for performing common
and queries on a DynamoDB table. It leverages an underlying persistence layer to handle CRUD operations and queries on a DynamoDB table.
serialization and deserialization of data, key composition, transaction operations, and TTL management. It leverages an underlying persistence layer to handle
serialization and deserialization of data, key composition, transaction operations,
and TTL management.
This collection class simplifies interaction with DynamoDB items, allowing users to: This collection class simplifies interaction with DynamoDB items, allowing users to:
- Retrieve a single item or multiple items via transactions. - Retrieve a single item or multiple items via transactions.
- Insert (put) items with optional TTL (time-to-live) settings. - Insert (put) items with optional TTL (time-to-live) settings.
- Delete items based on keys and conditions. - Delete items based on keys and conditions.
- Query items using partition keys or composite key pairs with optional filtering and pagination. - Query items using partition keys or composite key pairs with
optional filtering and pagination.
Parameters Parameters
---------- ----------
@@ -756,7 +791,6 @@ class DynamoDBCollection:
def get_item( def get_item(
self, self,
key: Key, key: Key,
path_spec: str | None = None,
/, /,
raise_on_error: bool = True, raise_on_error: bool = True,
exc_cls: Type[Exception] | None = None, exc_cls: Type[Exception] | None = None,
@@ -780,8 +814,6 @@ class DynamoDBCollection:
---------- ----------
key: Key key: Key
Key of the item to be retrieved. Key of the item to be retrieved.
path_spec: str, optional
A path specification for nested data extraction.
raise_on_error: bool, optional raise_on_error: bool, optional
If True, raises an exception when the item is not found. If True, raises an exception when the item is not found.
exc_cls: Type[Exception], optional exc_cls: Type[Exception], optional
@@ -797,10 +829,12 @@ class DynamoDBCollection:
Raises Raises
------ ------
Exception Exception
Raises the provided exception if the item is not found and raise_on_error is True. Raises the provided exception if the item is not found
and raise_on_error is True.
""" """
exc_cls = exc_cls or self.exc_cls exc_cls = exc_cls or self.exc_cls
data = self.persistence_layer.get_item(key) data = self.persistence_layer.get_item(key)
path_spec = getattr(key[SK], 'path_spec', None)
if raise_on_error and not data: if raise_on_error and not data:
raise exc_cls(f'Item with {key} not found.') raise exc_cls(f'Item with {key} not found.')
@@ -826,7 +860,8 @@ class DynamoDBCollection:
key: Key key: Key
Key for the item to be inserted or updated. Key for the item to be inserted or updated.
ttl: int or datetime, optional ttl: int or datetime, optional
Time-to-live for the item, specified as a timestamp integer or datetime object. Time-to-live for the item, specified as a timestamp integer
or datetime object.
**kwargs **kwargs
Additional data to be stored with the item. Additional data to be stored with the item.
@@ -836,18 +871,10 @@ class DynamoDBCollection:
True if the operation is successful, False otherwise. True if the operation is successful, False otherwise.
""" """
if isinstance(ttl, int): if isinstance(ttl, int):
kwargs.update( kwargs.update({'ttl': ttl})
{
'ttl': ttl,
}
)
if isinstance(ttl, datetime): if isinstance(ttl, datetime):
kwargs.update( kwargs.update({'ttl': timestamp(ttl)})
{
'ttl': timestamp(ttl),
}
)
return self.persistence_layer.put_item(item=key | kwargs) return self.persistence_layer.put_item(item=key | kwargs)
@@ -897,20 +924,42 @@ class DynamoDBCollection:
key = ( key = (
TransactKey('b3511b5a-cb32-4833-a373-f8223f2088d4') TransactKey('b3511b5a-cb32-4833-a373-f8223f2088d4')
+ SortKey('sk-1') + SortKey('sk-2') + SortKey('sk-1')
+ SortKey('sk-2')
)
collect = DynamoDBCollection(...)
items = collect.get_items(key)
**Get items using chained key pairs**
key = (
KeyPair('b3511b5a-cb32-4833-a373-f8223f2088d4', '0')
+ KeyPair('cpf', '07879819908')
+ KeyPair('email', 'user@example.com')
) )
collect = DynamoDBCollection(...) collect = DynamoDBCollection(...)
items = collect.get_items(key) items = collect.get_items(key)
Parameters Parameters
---------- ----------
key: TransactKey key: TransactKey or KeyChain
A TransactKey instance that contains a partition key and one or more sort keys. A `TransactKey` is used when you want to define a partition key (`pk`)
If no sort key is provided, the transaction is skipped. and append multiple `SortKey` instances using the `+` operator. Each
`SortKey` is internally paired with the `pk` to form a `KeyPair`.
Alternatively, a `KeyChain` can be created by chaining two or more `KeyPair`
instances using the `+` operator. For example:
`KeyPair(pk1, sk1) + KeyPair(pk2, sk2)` creates a `KeyChain` with two pairs.
Further additions to the chain extend it.
If no sort keys (in `TransactKey`) or no key pairs (in `KeyChain`)
are provided, the operation is skipped.
flatten_top: bool, optional flatten_top: bool, optional
Determines whether the first nested item in the transaction result should be flattened, Determines whether the first nested item in the transaction result
i.e., extracted to serve as the primary item at the top level of the returned dict. should be flattened,
i.e., extracted to serve as the primary item at the top level of
the returned dict.
If True, the nested item is promoted to the top level. If True, the nested item is promoted to the top level.
Returns Returns
@@ -949,21 +998,22 @@ class DynamoDBCollection:
return glom(obj, path_spec) return glom(obj, path_spec)
return obj return obj
def _removeprefix(pair: KeyPair) -> str: def _mapkey(pair: KeyPair) -> str:
pk = pair[PK] pk = pair[PK]
sk = pair[SK] sk = pair[SK]
if pair.rename_key:
return pair.rename_key
if not isinstance(sk, SortKey): if not isinstance(sk, SortKey):
return pk return pk
key = pk if sk.retain_key else sk key = pk if pair.retain_key else sk
return key.removeprefix(sk.remove_prefix or '') return key.removeprefix(sk.remove_prefix or '')
return head | { return head | {
_removeprefix(pair): _getin(pair, obj) _mapkey(pair): _getin(pair, obj) for pair, obj in zip(sortkeys, tail) if obj
for pair, obj in zip(sortkeys, tail)
if obj
} }
def query( def query(
@@ -1044,6 +1094,11 @@ class DynamoDBCollection:
_startkey_b64encode(response['last_key']) if response['last_key'] else None _startkey_b64encode(response['last_key']) if response['last_key'] else None
) )
def _removeprefix(
items: list[dict[str, Any]], /, key: str, prefix: str
) -> list[dict[str, Any]]:
return [x | {key: x[key].removeprefix(prefix)} for x in items]
match key.get(PK), key.get(SK): match key.get(PK), key.get(SK):
case ComposeKey(), _: # Remove prefix from Partition Key case ComposeKey(), _: # Remove prefix from Partition Key
items = _removeprefix(items, PK, key[PK].prefix + key[PK].delimiter) items = _removeprefix(items, PK, key[PK].prefix + key[PK].delimiter)
@@ -1056,16 +1111,6 @@ class DynamoDBCollection:
} }
def _removeprefix(
items: list[dict[str, Any]],
/,
key: str,
prefix: str,
) -> list[dict[str, Any]]:
"""Remove the given prefix from the value associated with key in each item."""
return [x | {key: x[key].removeprefix(prefix)} for x in items]
def _startkey_b64encode(obj: dict) -> str: def _startkey_b64encode(obj: dict) -> str:
s = json.dumps(obj) s = json.dumps(obj)
b = urlsafe_b64encode(s.encode('utf-8')).decode('utf-8') b = urlsafe_b64encode(s.encode('utf-8')).decode('utf-8')

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.3.1" version = "0.3.3"
description = "Packages shared dependencies to optimize deployment and ensure consistency across functions." description = "Packages shared dependencies to optimize deployment and ensure consistency across functions."
readme = "README.md" readme = "README.md"
authors = [ authors = [

View File

@@ -166,9 +166,11 @@ def test_collection_get_item_path_spec(
data = collect.get_item( data = collect.get_item(
KeyPair( KeyPair(
pk='5OxmMjL-ujoR5IMGegQz', pk='5OxmMjL-ujoR5IMGegQz',
sk=ComposeKey('sergio@somosbeta.com.br', prefix='emails'), sk=SortKey(
ComposeKey('sergio@somosbeta.com.br', prefix='emails'),
path_spec='mx_record_exists',
),
), ),
'mx_record_exists',
default={}, default={},
) )
assert data assert data
@@ -287,6 +289,20 @@ def test_collection_get_items(
} }
def test_collection_get_items_not_found(
dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
):
collect = DynamoDBCollection(dynamodb_persistence_layer)
doc = collect.get_items(
TransactKey('not_found')
+ SortKey('0')
+ SortKey('metadata#not_found', path_spec='payment_method')
)
assert doc == {}
def test_collection_get_items_unflatten( def test_collection_get_items_unflatten(
dynamodb_seeds, dynamodb_seeds,
dynamodb_persistence_layer: DynamoDBPersistenceLayer, dynamodb_persistence_layer: DynamoDBPersistenceLayer,
@@ -378,15 +394,20 @@ def test_collection_get_items_pair_path_spec(
): ):
collect = DynamoDBCollection(dynamodb_persistence_layer) collect = DynamoDBCollection(dynamodb_persistence_layer)
doc = collect.get_items( doc = collect.get_items(
KeyPair('cpf', SortKey('07879819908', path_spec='user_id', retain_key=True)) KeyPair(
'cpf',
SortKey('07879819908', path_spec='user_id'),
rename_key='user_id',
)
+ KeyPair( + KeyPair(
'email', 'email',
SortKey('osergiosiqueira@gmail.com', path_spec='user_id', retain_key=True), SortKey('osergiosiqueira@gmail.com', path_spec='user_id'),
retain_key=True,
), ),
flatten_top=False, flatten_top=False,
) )
assert doc == { assert doc == {
'cpf': '5OxmMjL-ujoR5IMGegQz', 'user_id': '5OxmMjL-ujoR5IMGegQz',
'email': '5OxmMjL-ujoR5IMGegQz', 'email': '5OxmMjL-ujoR5IMGegQz',
} }

2
layercake/uv.lock generated
View File

@@ -589,7 +589,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.3.0" version = "0.3.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },