From 863959826881185af597af9199721ec4c1c2c6d4 Mon Sep 17 00:00:00 2001 From: Felipe Nunes Date: Mon, 26 Jan 2026 09:22:52 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(pagination):=20implementar=20pagina?= =?UTF-8?q?=C3=A7=C3=A3o=20(BLCKID-1912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descrição curta do que foi feito... --- .../messaging/models/base_record.py | 138 ++++++++++++------ .../protocols/issue_credential/v2_0/routes.py | 86 +++++++---- aries_cloudagent/storage/askar.py | 86 ++++++++--- aries_cloudagent/storage/base.py | 79 +++++++--- aries_cloudagent/utils/uuid_utils.py | 24 +++ 5 files changed, 299 insertions(+), 114 deletions(-) create mode 100644 aries_cloudagent/utils/uuid_utils.py diff --git a/aries_cloudagent/messaging/models/base_record.py b/aries_cloudagent/messaging/models/base_record.py index d080696c88..68c5163d79 100644 --- a/aries_cloudagent/messaging/models/base_record.py +++ b/aries_cloudagent/messaging/models/base_record.py @@ -3,19 +3,27 @@ import json import logging import sys -import uuid from datetime import datetime from typing import Any, Mapping, Optional, Sequence, Type, TypeVar, Union from marshmallow import fields +from ...utils.uuid_utils import uuid4 from ...cache.base import BaseCache from ...config.settings import BaseSettings from ...core.profile import ProfileSession -from ...storage.base import BaseStorage, StorageDuplicateError, StorageNotFoundError +from ...storage.base import ( + DEFAULT_PAGE_SIZE, + BaseStorage, + StorageDuplicateError, + StorageNotFoundError, +) from ...storage.record import StorageRecord from ..util import datetime_to_str, time_now -from ..valid import INDY_ISO8601_DATETIME_EXAMPLE, INDY_ISO8601_DATETIME_VALIDATE +from ..valid import ( + INDY_ISO8601_DATETIME_EXAMPLE, + INDY_ISO8601_DATETIME_VALIDATE, +) from .base import BaseModel, BaseModelError, BaseModelSchema LOGGER = logging.getLogger(__name__) @@ -46,8 +54,7 @@ def match_post_filter( return ( positive and all( - record.get(k) and record.get(k) in alts - for k, alts in post_filter.items() + record.get(k) and record.get(k) in alts for k, alts in post_filter.items() ) ) or ( (not positive) @@ -224,11 +231,12 @@ async def retrieve_by_id( Args: session: The profile session to use record_id: The ID of the record to find + for_update: Whether to lock the record for update """ storage = session.inject(BaseStorage) result = await storage.get_record( - cls.RECORD_TYPE, record_id, {"forUpdate": for_update, "retrieveTags": False} + cls.RECORD_TYPE, record_id, options={"forUpdate": for_update} ) vals = json.loads(result.value) return cls.from_storage(record_id, vals) @@ -238,24 +246,26 @@ async def retrieve_by_tag_filter( cls: Type[RecordType], session: ProfileSession, tag_filter: dict, - post_filter: dict = None, + post_filter: Optional[dict] = None, *, for_update=False, ) -> RecordType: """Retrieve a record by tag filter. Args: + cls: The record class session: The profile session to use tag_filter: The filter dictionary to apply post_filter: Additional value filters to apply matching positively, with sequence values specifying alternatives to match (hit any) + for_update: Whether to lock the record for update """ storage = session.inject(BaseStorage) rows = await storage.find_all_records( cls.RECORD_TYPE, cls.prefix_tag_filter(tag_filter), - options={"forUpdate": for_update, "retrieveTags": False}, + options={"forUpdate": for_update}, ) found = None for record in rows: @@ -282,10 +292,14 @@ async def retrieve_by_tag_filter( async def query( cls: Type[RecordType], session: ProfileSession, - tag_filter: dict = None, + tag_filter: Optional[dict] = None, *, - post_filter_positive: dict = None, - post_filter_negative: dict = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + order_by: Optional[str] = None, + descending: bool = False, + post_filter_positive: Optional[dict] = None, + post_filter_negative: Optional[dict] = None, alt: bool = False, ) -> Sequence[RecordType]: """Query stored records. @@ -293,46 +307,84 @@ async def query( Args: session: The profile session to use tag_filter: An optional dictionary of tag filter clauses + limit: The maximum number of records to retrieve + offset: The offset to start retrieving records from + order_by: An optional field by which to order the records. + descending: Whether to order the records in descending order. post_filter_positive: Additional value filters to apply matching positively post_filter_negative: Additional value filters to apply matching negatively alt: set to match any (positive=True) value or miss all (positive=False) values in post_filter """ - storage = session.inject(BaseStorage) - rows = await storage.find_all_records( - cls.RECORD_TYPE, - cls.prefix_tag_filter(tag_filter), - options={"retrieveTags": False}, - ) + + tag_query = cls.prefix_tag_filter(tag_filter) + post_filter = post_filter_positive or post_filter_negative + + # set flag to indicate if pagination is requested or not, then set defaults + paginated = limit is not None or offset is not None + limit = limit or DEFAULT_PAGE_SIZE + offset = offset or 0 + + if not post_filter and paginated: + # Only fetch paginated records if post-filter is not being applied + rows = await storage.find_paginated_records( + type_filter=cls.RECORD_TYPE, + tag_query=tag_query, + limit=limit, + offset=offset, + order_by=order_by, + descending=descending, + ) + else: + rows = await storage.find_all_records( + type_filter=cls.RECORD_TYPE, + tag_query=tag_query, + order_by=order_by, + descending=descending, + ) + + num_results_post_filter = 0 # used if applying pagination post-filter + num_records_to_match = limit + offset # ignored if not paginated + result = [] for record in rows: - vals = json.loads(record.value) - if match_post_filter( - vals, - post_filter_positive, - positive=True, - alt=alt, - ) and match_post_filter( - vals, - post_filter_negative, - positive=False, - alt=alt, - ): - try: + try: + vals = json.loads(record.value) + if not post_filter: # pagination would already be applied if requested result.append(cls.from_storage(record.id, vals)) - except BaseModelError as err: - raise BaseModelError(f"{err}, for record id {record.id}") + else: + continue_processing = ( + not paginated or num_results_post_filter < num_records_to_match + ) + if not continue_processing: + break + + post_filter_match = match_post_filter( + vals, post_filter_positive, positive=True, alt=alt + ) and match_post_filter( + vals, post_filter_negative, positive=False, alt=alt + ) + + if not post_filter_match: + continue + + if num_results_post_filter >= offset: # append only after offset + result.append(cls.from_storage(record.id, vals)) + + num_results_post_filter += 1 + except (BaseModelError, json.JSONDecodeError, TypeError) as err: + raise BaseModelError(f"{err}, for record id {record.id}") return result async def save( self, session: ProfileSession, *, - reason: str = None, + reason: Optional[str] = None, log_params: Mapping[str, Any] = None, log_override: bool = False, - event: bool = None, + event: Optional[bool] = None, ) -> str: """Persist the record to storage. @@ -340,7 +392,7 @@ async def save( session: The profile session to use reason: A reason to add to the log log_params: Additional parameters to log - override: Override configured logging regimen, print to stderr instead + log_override: Override configured logging regimen, print to stderr instead event: Flag to override whether the event is sent """ @@ -355,7 +407,7 @@ async def save( new_record = False else: if not self._id: - self._id = str(uuid.uuid4()) + self._id = str(uuid4()) self.created_at = self.updated_at await storage.add_record(self.storage_record) new_record = True @@ -380,7 +432,7 @@ async def post_save( session: ProfileSession, new_record: bool, last_state: Optional[str], - event: bool = None, + event: Optional[bool] = None, ): """Perform post-save actions. @@ -411,7 +463,7 @@ async def delete_record(self, session: ProfileSession): await self.emit_event(session, self.serialize()) await storage.delete_record(self.storage_record) - async def emit_event(self, session: ProfileSession, payload: Any = None): + async def emit_event(self, session: ProfileSession, payload: Optional[Any] = None): """Emit an event. Args: @@ -436,12 +488,11 @@ async def emit_event(self, session: ProfileSession, payload: Any = None): def log_state( cls, msg: str, - params: dict = None, - settings: BaseSettings = None, + params: Optional[dict] = None, + settings: Optional[BaseSettings] = None, override: bool = False, ): """Print a message with increased visibility (for testing).""" - if override or ( cls.LOG_STATE_FLAG and settings and settings.get(cls.LOG_STATE_FLAG) ): @@ -454,10 +505,7 @@ def log_state( @classmethod def strip_tag_prefix(cls, tags: dict): """Strip tilde from unencrypted tag names.""" - - return ( - {(k[1:] if "~" in k else k): v for (k, v) in tags.items()} if tags else {} - ) + return {(k[1:] if "~" in k else k): v for (k, v) in tags.items()} if tags else {} @classmethod def prefix_tag_filter(cls, tag_filter: dict): diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index 1341ae4dc2..6f0ae3bc31 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -2,7 +2,7 @@ import logging from json.decoder import JSONDecodeError -from typing import Mapping +from typing import Mapping, Optional from aiohttp import web from aiohttp_apispec import ( @@ -57,6 +57,16 @@ LOGGER = logging.getLogger(__name__) +def get_limit_offset(request: web.BaseRequest): + """Pega limit e offset da request para o ACA-Py 0.12.""" + limit = request.query.get("limit") + offset = request.query.get("offset") + if limit: + limit = int(limit) + if offset: + offset = int(offset) + return limit, offset + class V20IssueCredentialModuleResponseSchema(OpenAPISchema): """Response schema for v2.0 Issue Credential Module.""" @@ -65,6 +75,14 @@ class V20IssueCredentialModuleResponseSchema(OpenAPISchema): class V20CredExRecordListQueryStringSchema(OpenAPISchema): """Parameters and validators for credential exchange record list query.""" + limit = fields.Int( + required=False, + metadata={"description": "Number of results to return", "example": 10}, + ) + offset = fields.Int( + required=False, + metadata={"description": "Offset for pagination", "example": 0}, + ) connection_id = fields.Str( required=False, metadata={"description": "Connection identifier", "example": UUID4_EXAMPLE}, @@ -108,6 +126,7 @@ class V20CredExRecordDetailSchema(OpenAPISchema): indy = fields.Nested(V20CredExRecordIndySchema, required=False) ld_proof = fields.Nested(V20CredExRecordLDProofSchema, required=False) + vc_di = fields.Nested(V20CredExRecordSchema, required=False) class V20CredExRecordListResultSchema(OpenAPISchema): @@ -187,10 +206,11 @@ class V20CredFilterSchema(OpenAPISchema): def validate_fields(self, data, **kwargs): """Validate schema fields. - Data must have indy, ld_proof, or both. + Data must have indy, ld_proof, vc_di, or all. Args: data: The data to validate + kwargs: Additional keyword arguments Raises: ValidationError: if data has neither indy nor ld_proof @@ -198,7 +218,7 @@ def validate_fields(self, data, **kwargs): """ if not any(f.api in data for f in V20CredFormat.Format): raise ValidationError( - "V20CredFilterSchema requires indy, ld_proof, or both" + "V20CredFilterSchema requires indy, ld_proof, vc_di or all" ) @@ -239,11 +259,13 @@ class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): @validates_schema def validate(self, data, **kwargs): - """Make sure preview is present when indy format is present.""" + """Make sure preview is present when indy/vc_di format is present.""" - if data.get("filter", {}).get("indy") and not data.get("credential_preview"): + if ( + data.get("filter", {}).get("indy") or data.get("filter", {}).get("vc_di") + ) and not data.get("credential_preview"): raise ValidationError( - "Credential preview is required if indy filter is present" + "Credential preview is required if indy or vc_di filter is present" ) @@ -515,12 +537,15 @@ async def credential_exchange_list(request: web.BaseRequest): for k in ("connection_id", "role", "state") if request.query.get(k, "") != "" } + limit, offset = get_limit_offset(request) try: async with profile.session() as session: cred_ex_records = await V20CredExRecord.query( session=session, tag_filter=tag_filter, + limit=limit, + offset=offset, post_filter_positive=post_filter, ) @@ -583,8 +608,7 @@ async def credential_exchange_retrieve(request: web.BaseRequest): @docs( tags=["issue-credential v2.0"], summary=( - "Create a credential record without " - "sending (generally for use with Out-Of-Band)" + "Create a credential record without sending (generally for use with Out-Of-Band)" ), ) @request_schema(V20IssueCredSchemaCore()) @@ -621,9 +645,7 @@ async def credential_exchange_create(request: web.BaseRequest): try: # Not all formats use credential preview - cred_preview = ( - V20CredPreview.deserialize(preview_spec) if preview_spec else None - ) + cred_preview = V20CredPreview.deserialize(preview_spec) if preview_spec else None cred_proposal = V20CredProposal( comment=comment, credential_preview=cred_preview, @@ -705,9 +727,7 @@ async def credential_exchange_send(request: web.BaseRequest): cred_ex_record = None try: # Not all formats use credential preview - cred_preview = ( - V20CredPreview.deserialize(preview_spec) if preview_spec else None - ) + cred_preview = V20CredPreview.deserialize(preview_spec) if preview_spec else None async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: @@ -813,9 +833,7 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): conn_record = None cred_ex_record = None try: - cred_preview = ( - V20CredPreview.deserialize(preview_spec) if preview_spec else None - ) + cred_preview = V20CredPreview.deserialize(preview_spec) if preview_spec else None async with profile.session() as session: conn_record = await ConnRecord.retrieve_by_id(session, connection_id) if not conn_record.is_ready: @@ -859,14 +877,14 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): async def _create_free_offer( profile: Profile, - filt_spec: Mapping = None, - connection_id: str = None, + filt_spec: Optional[Mapping] = None, + connection_id: Optional[str] = None, auto_issue: bool = False, auto_remove: bool = False, - replacement_id: str = None, - preview_spec: dict = None, - comment: str = None, - trace_msg: bool = None, + replacement_id: Optional[str] = None, + preview_spec: Optional[dict] = None, + comment: Optional[str] = None, + trace_msg: Optional[bool] = None, ): """Create a credential offer and related exchange record.""" @@ -892,11 +910,17 @@ async def _create_free_offer( ) cred_manager = V20CredManager(profile) - (cred_ex_record, cred_offer_message) = await cred_manager.create_offer( - cred_ex_record, - comment=comment, - replacement_id=replacement_id, - ) + try: + (cred_ex_record, cred_offer_message) = await cred_manager.create_offer( + cred_ex_record, + comment=comment, + replacement_id=replacement_id, + ) + except ValueError as err: + LOGGER.exception(f"Error creating credential offer: {err}") + async with profile.session() as session: + await cred_ex_record.save_error_state(session, reason=err) + raise web.HTTPBadRequest(reason=err) return (cred_ex_record, cred_offer_message) @@ -1159,7 +1183,7 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): outbound_handler, ) except LinkedDataProofException as err: - raise web.HTTPBadRequest(reason=err) from err + raise web.HTTPBadRequest(reason=str(err)) from err await outbound_handler(cred_offer_message, connection_id=connection_id) @@ -1472,9 +1496,7 @@ async def credential_exchange_issue(request: web.BaseRequest): outbound_handler, ) - await outbound_handler( - cred_issue_message, connection_id=cred_ex_record.connection_id - ) + await outbound_handler(cred_issue_message, connection_id=cred_ex_record.connection_id) trace_event( context.settings, diff --git a/aries_cloudagent/storage/askar.py b/aries_cloudagent/storage/askar.py index bab4cc4ff7..d557863b29 100644 --- a/aries_cloudagent/storage/askar.py +++ b/aries_cloudagent/storage/askar.py @@ -1,11 +1,10 @@ """Aries-Askar implementation of BaseStorage interface.""" -from typing import Mapping, Sequence +from typing import Mapping, Optional, Sequence from aries_askar import AskarError, AskarErrorCode, Session from ..askar.profile import AskarProfile, AskarProfileSession - from .base import ( DEFAULT_PAGE_SIZE, BaseStorage, @@ -14,8 +13,8 @@ validate_record, ) from .error import ( - StorageError, StorageDuplicateError, + StorageError, StorageNotFoundError, StorageSearchError, ) @@ -58,7 +57,7 @@ async def add_record(self, record: StorageRecord): raise StorageError("Error when adding storage record") from err async def get_record( - self, record_type: str, record_id: str, options: Mapping = None + self, record_type: str, record_id: str, options: Optional[Mapping] = None ) -> StorageRecord: """Fetch a record from the store by type and ID. @@ -141,7 +140,7 @@ async def delete_record(self, record: StorageRecord): raise StorageError("Error when removing storage record") from err async def find_record( - self, type_filter: str, tag_query: Mapping, options: Mapping = None + self, type_filter: str, tag_query: Mapping, options: Optional[Mapping] = None ) -> StorageRecord: """Find a record using a unique tag filter. @@ -169,17 +168,56 @@ async def find_record( tags=row.tags, ) + async def find_paginated_records( + self, + type_filter: str, + tag_query: Optional[Mapping] = None, + limit: int = DEFAULT_PAGE_SIZE, + offset: int = 0, + order_by: Optional[str] = None, + descending: bool = False, + ) -> Sequence[StorageRecord]: + """Retrieve a page of records matching a particular type filter and tag query.""" + results = [] + + store = self._session.profile.store + + async for row in store.scan( + category=type_filter, + tag_filter=tag_query, + limit=limit, + offset=offset, + order_by=order_by, + descending=descending, + profile=self._session.profile.settings.get("wallet.askar_profile"), + ): + results.append( + StorageRecord( + type=row.category, + id=row.name, + value=None if row.value is None else row.value.decode("utf-8"), + tags=row.tags, + ) + ) + return results + async def find_all_records( self, type_filter: str, - tag_query: Mapping = None, - options: Mapping = None, + tag_query: Optional[Mapping] = None, + order_by: Optional[str] = None, + descending: bool = False, + options: Optional[Mapping] = None, ): """Retrieve all records matching a particular type filter and tag query.""" for_update = bool(options and options.get("forUpdate")) results = [] for row in await self._session.handle.fetch_all( - type_filter, tag_query, for_update=for_update + category=type_filter, + tag_filter=tag_query, + order_by=order_by, + descending=descending, + for_update=for_update, ): results.append( StorageRecord( @@ -194,7 +232,7 @@ async def find_all_records( async def delete_all_records( self, type_filter: str, - tag_query: Mapping = None, + tag_query: Optional[Mapping] = None, ): """Remove all records matching a particular type filter and tag query.""" await self._session.handle.remove_all(type_filter, tag_query) @@ -208,15 +246,16 @@ def __init__(self, profile: AskarProfile): Args: profile: The Askar profile instance to use + """ self._profile = profile def search_records( self, type_filter: str, - tag_query: Mapping = None, - page_size: int = None, - options: Mapping = None, + tag_query: Optional[Mapping] = None, + page_size: Optional[int] = None, + options: Optional[Mapping] = None, ) -> "AskarStorageSearchSession": """Search stored records. @@ -243,8 +282,8 @@ def __init__( profile: AskarProfile, type_filter: str, tag_query: Mapping, - page_size: int = None, - options: Mapping = None, + page_size: Optional[int] = None, + options: Optional[Mapping] = None, ): """Initialize a `AskarStorageSearchSession` instance. @@ -253,6 +292,7 @@ def __init__( type_filter: Filter string tag_query: Tags to search page_size: Size of page to return + options: Dictionary of backend-specific options """ self.tag_query = tag_query @@ -307,11 +347,14 @@ async def __anext__(self): tags=row.tags, ) - async def fetch(self, max_count: int = None) -> Sequence[StorageRecord]: + async def fetch( + self, max_count: Optional[int] = None, offset: Optional[int] = None + ) -> Sequence[StorageRecord]: """Fetch the next list of results from the store. Args: max_count: Max number of records to return + offset: The offset to start retrieving records from Returns: A list of `StorageRecord` instances @@ -322,11 +365,12 @@ async def fetch(self, max_count: int = None) -> Sequence[StorageRecord]: """ if self._done: raise StorageSearchError("Search query is complete") - await self._open() + + limit = max_count or self.page_size + await self._open(limit=limit, offset=offset) count = 0 ret = [] - limit = max_count or self.page_size while count < limit: try: @@ -352,14 +396,16 @@ async def fetch(self, max_count: int = None) -> Sequence[StorageRecord]: return ret - async def _open(self): + async def _open(self, offset: Optional[int] = None, limit: Optional[int] = None): """Start the search query.""" if self._scan: return try: self._scan = self._profile.store.scan( - self.type_filter, - self.tag_query, + category=self.type_filter, + tag_filter=self.tag_query, + offset=offset, + limit=limit, profile=self._profile.settings.get("wallet.askar_profile"), ) except AskarError as err: diff --git a/aries_cloudagent/storage/base.py b/aries_cloudagent/storage/base.py index 8eb798ffbf..9ad75966f6 100644 --- a/aries_cloudagent/storage/base.py +++ b/aries_cloudagent/storage/base.py @@ -1,13 +1,13 @@ """Abstract base classes for non-secrets storage.""" from abc import ABC, abstractmethod -from typing import Mapping, Sequence +from typing import Mapping, Optional, Sequence -from .error import StorageError, StorageDuplicateError, StorageNotFoundError +from .error import StorageDuplicateError, StorageError, StorageNotFoundError from .record import StorageRecord - DEFAULT_PAGE_SIZE = 100 +MAXIMUM_PAGE_SIZE = 10000 def validate_record(record: StorageRecord, *, delete=False): @@ -36,7 +36,7 @@ async def add_record(self, record: StorageRecord): @abstractmethod async def get_record( - self, record_type: str, record_id: str, options: Mapping = None + self, record_type: str, record_id: str, options: Optional[Mapping] = None ) -> StorageRecord: """Fetch a record from the store by type and ID. @@ -71,7 +71,10 @@ async def delete_record(self, record: StorageRecord): """ async def find_record( - self, type_filter: str, tag_query: Mapping = None, options: Mapping = None + self, + type_filter: str, + tag_query: Optional[Mapping] = None, + options: Optional[Mapping] = None, ) -> StorageRecord: """Find a record using a unique tag filter. @@ -89,22 +92,64 @@ async def find_record( raise StorageDuplicateError("Duplicate records found") return results[0] + @abstractmethod + async def find_paginated_records( + self, + type_filter: str, + tag_query: Optional[Mapping] = None, + limit: int = DEFAULT_PAGE_SIZE, + offset: int = 0, + order_by: Optional[str] = None, + descending: bool = False, + ) -> Sequence[StorageRecord]: + """Retrieve a page of records matching a particular type filter and tag query. + + Args: + type_filter: The type of records to filter by + tag_query: An optional dictionary of tag filter clauses + limit: The maximum number of records to retrieve + offset: The offset to start retrieving records from + order_by: An optional field by which to order the records. + descending: Whether to order the records in descending order. + + Returns: + A sequence of StorageRecord matching the filter and query parameters. + + """ + @abstractmethod async def find_all_records( self, type_filter: str, - tag_query: Mapping = None, - options: Mapping = None, - ): - """Retrieve all records matching a particular type filter and tag query.""" + tag_query: Optional[Mapping] = None, + order_by: Optional[str] = None, + descending: bool = False, + options: Optional[Mapping] = None, + ) -> Sequence[StorageRecord]: + """Retrieve all records matching a particular type filter and tag query. + + Args: + type_filter: The type of records to filter by. + tag_query: An optional dictionary of tag filter clauses. + order_by: An optional field by which to order the records. + descending: Whether to order the records in descending order. + options: Additional options for the query. + + """ @abstractmethod async def delete_all_records( self, type_filter: str, - tag_query: Mapping = None, - ): - """Remove all records matching a particular type filter and tag query.""" + tag_query: Optional[Mapping] = None, + ) -> None: + """Remove all records matching a particular type filter and tag query. + + Args: + type_filter: The type of records to filter by. + tag_query: An optional dictionary of tag filter clauses. + + """ class BaseStorageSearch(ABC): @@ -114,9 +159,9 @@ class BaseStorageSearch(ABC): def search_records( self, type_filter: str, - tag_query: Mapping = None, - page_size: int = None, - options: Mapping = None, + tag_query: Optional[Mapping] = None, + page_size: Optional[int] = None, + options: Optional[Mapping] = None, ) -> "BaseStorageSearchSession": """Create a new record query. @@ -140,7 +185,7 @@ class BaseStorageSearchSession(ABC): """Abstract stored records search session interface.""" @abstractmethod - async def fetch(self, max_count: int = None) -> Sequence[StorageRecord]: + async def fetch(self, max_count: Optional[int] = None) -> Sequence[StorageRecord]: """Fetch the next list of results from the store. Args: @@ -167,7 +212,7 @@ def __repr__(self) -> str: class IterSearch: """A generic record search async iterator.""" - def __init__(self, search: BaseStorageSearchSession, page_size: int = None): + def __init__(self, search: BaseStorageSearchSession, page_size: Optional[int] = None): """Instantiate a new `IterSearch` instance.""" self._buffer = None self._page_size = page_size diff --git a/aries_cloudagent/utils/uuid_utils.py b/aries_cloudagent/utils/uuid_utils.py new file mode 100644 index 0000000000..8c2dcb8a56 --- /dev/null +++ b/aries_cloudagent/utils/uuid_utils.py @@ -0,0 +1,24 @@ +"""UUID utilities shim used by the package. + +This module provides a small wrapper around the standard library's +`uuid.uuid4()` so modules that import `uuid4` keep working. We put it +under `aries_cloudagent.utils` and import it with a package-relative +import to avoid depending on a top-level `uuid_utils` package that may +not be installed. +""" + +from __future__ import annotations + +import uuid +from typing import Any + + +def uuid4() -> Any: + """Return a new UUID value. + + The original codebase sometimes expects a string, sometimes a + UUID-like object. For compatibility we return a UUID instance; call + sites that expect strings usually convert with `str()`. + """ + + return uuid.uuid4() From 26908af34ce3568698e9f83c0488eea25482a721 Mon Sep 17 00:00:00 2001 From: Felipe Nunes Date: Mon, 26 Jan 2026 11:33:55 -0300 Subject: [PATCH 2/2] fix: implementar find_paginated_records em InMemoryStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona implementação do método find_paginated_records na classe InMemoryStorage para corrigir erro de classe abstrata. O método foi declarado como abstrato em BaseStorage mas não foi implementado em InMemoryStorage, causando TypeError nos testes. Também atualiza assinaturas de find_all_records para incluir parâmetros order_by e descending para consistência com BaseStorage. --- aries_cloudagent/storage/in_memory.py | 39 ++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/aries_cloudagent/storage/in_memory.py b/aries_cloudagent/storage/in_memory.py index d2b0671f4f..2ac39d7843 100644 --- a/aries_cloudagent/storage/in_memory.py +++ b/aries_cloudagent/storage/in_memory.py @@ -1,6 +1,6 @@ """Basic in-memory storage implementation (non-wallet).""" -from typing import Mapping, Sequence +from typing import Mapping, Optional, Sequence from ..core.in_memory import InMemoryProfile @@ -105,8 +105,10 @@ async def delete_record(self, record: StorageRecord): async def find_all_records( self, type_filter: str, - tag_query: Mapping = None, - options: Mapping = None, + tag_query: Optional[Mapping] = None, + order_by: Optional[str] = None, + descending: bool = False, + options: Optional[Mapping] = None, ): """Retrieve all records matching a particular type filter and tag query.""" results = [] @@ -115,6 +117,37 @@ async def find_all_records( results.append(record) return results + async def find_paginated_records( + self, + type_filter: str, + tag_query: Optional[Mapping] = None, + limit: int = DEFAULT_PAGE_SIZE, + offset: int = 0, + order_by: Optional[str] = None, + descending: bool = False, + ) -> Sequence[StorageRecord]: + """Retrieve a page of records matching a particular type filter and tag query. + + Args: + type_filter: The type of records to filter by + tag_query: An optional dictionary of tag filter clauses + limit: The maximum number of records to retrieve + offset: The offset to start retrieving records from + order_by: An optional field by which to order the records (not supported in InMemoryStorage) + descending: Whether to order the records in descending order (not supported in InMemoryStorage) + + Returns: + A sequence of StorageRecord matching the filter and query parameters + """ + # Get all matching records + results = [] + for record in self.profile.records.values(): + if record.type == type_filter and tag_query_match(record.tags, tag_query): + results.append(record) + + # Apply pagination + return results[offset:offset + limit] + async def delete_all_records( self, type_filter: str,