From 2d8bfbadd4fe7e7ec9dce5e6a16c8812faa711c6 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 24 Apr 2026 11:40:03 -0400 Subject: [PATCH 01/10] implement synchronize endpoint; add test --- .../core/constants/concrete_types.py | 1 + synapseclient/models/curation.py | 139 ++++++++++++++++++ .../models/mixins/asynchronous_job.py | 2 + .../models/async/unit_test_curation_async.py | 1 + 4 files changed, 143 insertions(+) diff --git a/synapseclient/core/constants/concrete_types.py b/synapseclient/core/constants/concrete_types.py index 6cc975ec5..5ab6ede15 100644 --- a/synapseclient/core/constants/concrete_types.py +++ b/synapseclient/core/constants/concrete_types.py @@ -149,3 +149,4 @@ LIST_GRID_SESSIONS_RESPONSE = ( "org.sagebionetworks.repo.model.grid.ListGridSessionsResponse" ) +SYNCHRONIZE_GRID_REQUEST = "org.sagebionetworks.repo.model.grid.SynchronizeGridRequest" diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 7214522be..b091206e8 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -32,6 +32,7 @@ LIST_GRID_SESSIONS_REQUEST, LIST_GRID_SESSIONS_RESPONSE, RECORD_BASED_METADATA_TASK_PROPERTIES, + SYNCHRONIZE_GRID_REQUEST, ) from synapseclient.core.utils import delete_none_keys, merge_dataclass_entities from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator @@ -1078,6 +1079,53 @@ def to_synapse_request(self) -> Dict[str, Any]: return request_dict +@dataclass +class SynchronizeGridRequest(AsynchronousCommunicator): + """ + A request to synchronize a grid session. + + The request is modeled from: + + The response is modeled from: + """ + + session_id: str = "" + """The ID of the grid session to synchronize.""" + + concrete_type: str = field(default=SYNCHRONIZE_GRID_REQUEST) + """The concrete type for this request.""" + + error_messages: Optional[list[str]] = field(default=None, compare=False) + """Any error messages generated during the synchronization process.""" + + def fill_from_dict( + self, synapse_response: Dict[str, Any] + ) -> "SynchronizeGridRequest": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response from the REST API. + + Returns: + The SynchronizeGridRequest object. + """ + self.error_messages = synapse_response.get("errorMessages", None) + return self + + def to_synapse_request(self) -> Dict[str, Any]: + """ + Converts this dataclass to a dictionary suitable for a Synapse REST API request. + + Returns: + A dictionary representation of this object for API requests. + """ + return { + "concreteType": self.concrete_type, + "gridSessionId": self.session_id, + } + + @dataclass class GridSession: """ @@ -1339,6 +1387,41 @@ def export_to_record_set( """ return self + def synchronize( + self, *, timeout: int = 120, synapse_client: Optional[Synapse] = None + ) -> "Grid": + """ + Synchronizes the grid session's schema and row data against its source entity. + + Arguments: + timeout: The number of seconds to wait for the job to complete or progress + before raising a SynapseTimeoutError. Defaults to 120. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Grid: The Grid object. + + Raises: + ValueError: If session_id is not provided. + + Example: Synchronize a grid session +   + + ```python + from synapseclient import Synapse + from synapseclient.models import Grid + + syn = Synapse() + syn.login() + + grid = Grid(session_id="abc-123-def") + grid = grid.synchronize() + ``` + """ + return self + def delete(self, *, synapse_client: Optional[Synapse] = None) -> None: """ Delete the grid session. @@ -1838,3 +1921,59 @@ async def main(): await delete_grid_session( session_id=self.session_id, synapse_client=synapse_client ) + + async def synchronize_async( + self, *, timeout: int = 120, synapse_client: Optional[Synapse] = None + ) -> "Grid": + """ + Synchronizes the grid session's schema and row data against its source entity. + + Arguments: + timeout: The number of seconds to wait for the job to complete or progress + before raising a SynapseTimeoutError. Defaults to 120. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + Grid: The Grid object. + + Raises: + ValueError: If session_id is not provided. + + Example: Synchronize a grid session asynchronously +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Grid + + syn = Synapse() + syn.login() + + async def main(): + grid = Grid(session_id="abc-123-def") + grid = await grid.synchronize_async() + + asyncio.run(main()) + ``` + """ + if not self.session_id: + raise ValueError("session_id is required to synchronize a GridSession") + + trace.get_current_span().set_attributes({"synapse.session_id": self.session_id}) + + request = SynchronizeGridRequest(session_id=self.session_id) + result = await request.send_job_and_wait_async( + timeout=timeout, synapse_client=synapse_client + ) + + if result.error_messages: + client = Synapse.get_client(synapse_client=synapse_client) + client.logger.warning( + f"Grid session '{self.session_id}' synchronization completed with " + f"error messages: {result.error_messages}" + ) + + return self diff --git a/synapseclient/models/mixins/asynchronous_job.py b/synapseclient/models/mixins/asynchronous_job.py index 407babe92..f327e8652 100644 --- a/synapseclient/models/mixins/asynchronous_job.py +++ b/synapseclient/models/mixins/asynchronous_job.py @@ -19,6 +19,7 @@ GRID_RECORD_SET_EXPORT_REQUEST, QUERY_BUNDLE_REQUEST, QUERY_TABLE_CSV_REQUEST, + SYNCHRONIZE_GRID_REQUEST, TABLE_UPDATE_TRANSACTION_REQUEST, ) from synapseclient.core.exceptions import ( @@ -32,6 +33,7 @@ CREATE_GRID_REQUEST: "/grid/session/async", DOWNLOAD_LIST_MANIFEST_REQUEST: "/download/list/manifest/async", GRID_RECORD_SET_EXPORT_REQUEST: "/grid/export/recordset/async", + SYNCHRONIZE_GRID_REQUEST: "/grid/synchronize/async", TABLE_UPDATE_TRANSACTION_REQUEST: "/entity/{entityId}/table/transaction/async", GET_VALIDATION_SCHEMA_REQUEST: "/schema/type/validation/async", CREATE_SCHEMA_REQUEST: "/schema/type/create/async", diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index c7a92e0cd..1af590dac 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -16,6 +16,7 @@ Grid, GridRecordSetExportRequest, RecordBasedMetadataTaskProperties, + SynchronizeGridRequest, _create_task_properties_from_dict, ) from synapseclient.models.recordset import ValidationSummary From d7bf731f1cc22a4ac089ddec1fba2a1bec751d03 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 24 Apr 2026 11:43:56 -0400 Subject: [PATCH 02/10] add unit test --- .../models/async/unit_test_curation_async.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index 1af590dac..034cb66e3 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -916,3 +916,33 @@ def test_to_synapse_request(self) -> None: # THEN it should contain the correct fields assert "concreteType" in result assert result["sessionId"] == SESSION_ID + + +class TestSynchronizeGridRequest: + def test_to_synapse_request(self) -> None: + # GIVEN a SynchronizeGridRequest with all fields set + sync_req = SynchronizeGridRequest( + session_id=SESSION_ID, + ) + + # WHEN I convert it to a synapse request + result = sync_req.to_synapse_request() + + # THEN it should contain the correct fields + assert "concreteType" in result + assert result["gridSessionId"] == SESSION_ID + + def test_fill_from_dict(self) -> None: + # GIVEN a response with synchronize grid session data + raw_response = { + "jobId": "1234", + "concreteType": "org.sagebionetworks.repo.model.grid.SynchronizeGridResponse", + "gridSessionId": SESSION_ID, + "errorMessages": ["test_error"], + } + + # WHEN I fill a SynchronizeGridRequest from the response + sync_req = SynchronizeGridRequest(session_id=SESSION_ID) + response = sync_req.fill_from_dict(raw_response) + assert "test_error" in response.error_messages + assert response.session_id == SESSION_ID From 7f65630302aa4eec1d3353887196843d893c26f3 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 24 Apr 2026 11:50:57 -0400 Subject: [PATCH 03/10] update docstring --- synapseclient/models/curation.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index b091206e8..10b6b12ad 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -1393,6 +1393,9 @@ def synchronize( """ Synchronizes the grid session's schema and row data against its source entity. + This is intended for grid sessions created from a file view via `initial_query`. + Grid sessions backed by a RecordSet should use `export_to_record_set` instead. + Arguments: timeout: The number of seconds to wait for the job to complete or progress before raising a SynapseTimeoutError. Defaults to 120. @@ -1406,17 +1409,23 @@ def synchronize( Raises: ValueError: If session_id is not provided. - Example: Synchronize a grid session + Example: Synchronize a grid session created from a file view   ```python from synapseclient import Synapse from synapseclient.models import Grid + from synapseclient.models.table_components import Query syn = Synapse() syn.login() - grid = Grid(session_id="abc-123-def") + # First create a grid session from a file view + query = Query(sql="SELECT * FROM syn1234567") + grid = Grid(initial_query=query) + grid = grid.create() + + # Synchronize the grid with the latest state of the file view grid = grid.synchronize() ``` """ @@ -1928,6 +1937,9 @@ async def synchronize_async( """ Synchronizes the grid session's schema and row data against its source entity. + This is intended for grid sessions created from a file view via `initial_query`. + Grid sessions backed by a RecordSet should use `export_to_record_set` instead. + Arguments: timeout: The number of seconds to wait for the job to complete or progress before raising a SynapseTimeoutError. Defaults to 120. @@ -1941,19 +1953,25 @@ async def synchronize_async( Raises: ValueError: If session_id is not provided. - Example: Synchronize a grid session asynchronously + Example: Synchronize a grid session created from a file view   ```python import asyncio from synapseclient import Synapse from synapseclient.models import Grid + from synapseclient.models.table_components import Query syn = Synapse() syn.login() async def main(): - grid = Grid(session_id="abc-123-def") + # First create a grid session from a file view + query = Query(sql="SELECT * FROM syn1234567") + grid = Grid(initial_query=query) + grid = await grid.create_async() + + # Synchronize the grid with the latest state of the file view grid = await grid.synchronize_async() asyncio.run(main()) From c59404dcbdaaa4a0a3b1579a69c49c7e2e380200 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 24 Apr 2026 12:09:25 -0400 Subject: [PATCH 04/10] change level to error; update doc; add unit test --- docs/reference/experimental/async/curator.md | 1 + docs/reference/experimental/sync/curator.md | 1 + synapseclient/models/curation.py | 2 +- .../models/async/unit_test_curation_async.py | 57 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/reference/experimental/async/curator.md b/docs/reference/experimental/async/curator.md index bf292948b..8e4283ca7 100644 --- a/docs/reference/experimental/async/curator.md +++ b/docs/reference/experimental/async/curator.md @@ -56,6 +56,7 @@ at your own risk. members: - create_async - export_to_record_set_async + - synchronize_async --- [](){ #query-reference-async } ::: synapseclient.models.Query diff --git a/docs/reference/experimental/sync/curator.md b/docs/reference/experimental/sync/curator.md index b02244aab..ec0b2d399 100644 --- a/docs/reference/experimental/sync/curator.md +++ b/docs/reference/experimental/sync/curator.md @@ -56,6 +56,7 @@ at your own risk. members: - create - export_to_record_set + - synchronize --- [](){ #query-reference } ::: synapseclient.models.Query diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 10b6b12ad..b36669253 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -1989,7 +1989,7 @@ async def main(): if result.error_messages: client = Synapse.get_client(synapse_client=synapse_client) - client.logger.warning( + client.logger.error( f"Grid session '{self.session_id}' synchronization completed with " f"error messages: {result.error_messages}" ) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index 034cb66e3..7b5b5d682 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -946,3 +946,60 @@ def test_fill_from_dict(self) -> None: response = sync_req.fill_from_dict(raw_response) assert "test_error" in response.error_messages assert response.session_id == SESSION_ID + + +class TestSynchronizeGrid: + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + async def test_synchronize_grid_async_without_session_id_raises(self) -> None: + # GIVEN a Grid without a session_id + grid = Grid() + + # WHEN I call synchronize_async + # THEN it should raise ValueError + with pytest.raises(ValueError, match="session_id is required to synchronize"): + await grid.synchronize_async(synapse_client=self.syn) + + async def test_synchronize_grid_async_empty_error(self) -> None: + # GIVEN a Grid with a session_id + grid = Grid(session_id=SESSION_ID) + mock_sync_response = SynchronizeGridRequest( + session_id=SESSION_ID, + error_messages=[], + ) + + # WHEN I call synchronize_async + with patch( + "synapseclient.models.curation.SynchronizeGridRequest.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_sync_response, + ) as mock_sync: + await grid.synchronize_async(synapse_client=self.syn) + + # THEN the API should be called with the session_id + mock_sync.assert_called_once_with(synapse_client=self.syn, timeout=120) + + async def test_synchronize_grid_async_with_errors(self) -> None: + # GIVEN a Grid with a session_id + grid = Grid(session_id=SESSION_ID) + mock_sync_response = SynchronizeGridRequest( + session_id=SESSION_ID, + error_messages=["sync_error_1", "sync_error_2"], + ) + + # WHEN I call synchronize_async + with patch( + "synapseclient.models.curation.SynchronizeGridRequest.send_job_and_wait_async", + new_callable=AsyncMock, + return_value=mock_sync_response, + ): + with patch.object(self.syn, "logger") as mock_logger: + await grid.synchronize_async(synapse_client=self.syn) + + # THEN the error messages should be logged as an error + mock_logger.error.assert_called_once() + error_message = mock_logger.error.call_args[0][0] + assert "sync_error_1" in error_message + assert "sync_error_2" in error_message From 20279b9255f83adbc5b5f4c78ca5629bcf984370 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 24 Apr 2026 13:36:02 -0400 Subject: [PATCH 05/10] cleanup test and add an integration test --- .../models/async/test_curation_async.py | 65 ------------------- .../models/async/test_grid_async.py | 52 ++++++++++++++- 2 files changed, 51 insertions(+), 66 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index 14111aa13..b2715f2fd 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -49,27 +49,11 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create an EntityView for the folder - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, - columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -277,28 +261,11 @@ async def folder_with_view( ).store_async(synapse_client=syn) schedule_for_cleanup(folder.id) - # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, - columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -387,27 +354,11 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, - columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -605,27 +556,11 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create required columns for the EntityView - columns = [ - Column(name="id", column_type=ColumnType.ENTITYID), - Column(name="name", column_type=ColumnType.STRING, maximum_size=256), - Column(name="createdOn", column_type=ColumnType.DATE), - Column(name="createdBy", column_type=ColumnType.USERID), - Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), - Column(name="type", column_type=ColumnType.STRING, maximum_size=64), - Column(name="parentId", column_type=ColumnType.ENTITYID), - Column(name="benefactorId", column_type=ColumnType.ENTITYID), - Column(name="projectId", column_type=ColumnType.ENTITYID), - Column(name="modifiedOn", column_type=ColumnType.DATE), - Column(name="modifiedBy", column_type=ColumnType.USERID), - Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), - ] - entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, - columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index cd16a0cf0..868df0d9c 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -9,7 +9,16 @@ import pytest from synapseclient import Synapse -from synapseclient.models import Grid, Project, RecordSet +from synapseclient.extensions.curator.file_based_metadata_task import EntityView +from synapseclient.models import ( + EntityView, + Folder, + Grid, + Project, + RecordSet, + ViewTypeMask, +) +from synapseclient.models.table_components import Query from tests.integration import ASYNC_JOB_TIMEOUT_SEC @@ -21,6 +30,31 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup + @pytest.fixture(scope="class") + async def entity_view( + self, + project_model: Project, + syn: Synapse, + schedule_for_cleanup: Callable[..., None], + ) -> tuple[Folder, EntityView]: + """Create a folder with an associated EntityView for file-based testing.""" + # Create a folder + folder = await Folder( + name=str(uuid.uuid4()), + parent_id=project_model.id, + ).store_async(synapse_client=syn) + schedule_for_cleanup(folder.id) + + entity_view = await EntityView( + name=str(uuid.uuid4()), + parent_id=project_model.id, + scope_ids=[folder.id], + view_type_mask=ViewTypeMask.FILE.value, + ).store_async(synapse_client=syn) + schedule_for_cleanup(entity_view.id) + + return entity_view + @pytest.fixture(scope="function") async def record_set_fixture(self, project_model: Project) -> RecordSet: """Create a RecordSet fixture for Grid testing.""" @@ -183,3 +217,19 @@ async def test_delete_grid_session_validation_error_async(self) -> None: match="session_id is required to delete a GridSession", ): await grid.delete_async(synapse_client=self.syn) + + async def test_synchronize_grid_async( + self, + entity_view: EntityView, + ) -> None: + + # GIVEN: A Grid with a session_id + query = Query(sql=f"SELECT * FROM {entity_view.id}") + grid = Grid(initial_query=query) + created_grid = await grid.create_async(synapse_client=self.syn) + + # WHEN: Synchronizing the grid session + grid = await created_grid.synchronize_async(synapse_client=self.syn) + assert grid.source_entity_id == entity_view.id + assert grid.session_id == created_grid.session_id + assert grid.source_entity_id == entity_view.id From f0a3a442d587f3555756d1cc633fc59c45b806d3 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 7 May 2026 11:22:01 -0400 Subject: [PATCH 06/10] restore the fixture --- .../models/async/test_curation_async.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/integration/synapseclient/models/async/test_curation_async.py b/tests/integration/synapseclient/models/async/test_curation_async.py index b2715f2fd..14111aa13 100644 --- a/tests/integration/synapseclient/models/async/test_curation_async.py +++ b/tests/integration/synapseclient/models/async/test_curation_async.py @@ -49,11 +49,27 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create an EntityView for the folder + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + Column(name="createdOn", column_type=ColumnType.DATE), + Column(name="createdBy", column_type=ColumnType.USERID), + Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), + Column(name="type", column_type=ColumnType.STRING, maximum_size=64), + Column(name="parentId", column_type=ColumnType.ENTITYID), + Column(name="benefactorId", column_type=ColumnType.ENTITYID), + Column(name="projectId", column_type=ColumnType.ENTITYID), + Column(name="modifiedOn", column_type=ColumnType.DATE), + Column(name="modifiedBy", column_type=ColumnType.USERID), + Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), + ] + entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, + columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -261,11 +277,28 @@ async def folder_with_view( ).store_async(synapse_client=syn) schedule_for_cleanup(folder.id) + # Create required columns for the EntityView + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + Column(name="createdOn", column_type=ColumnType.DATE), + Column(name="createdBy", column_type=ColumnType.USERID), + Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), + Column(name="type", column_type=ColumnType.STRING, maximum_size=64), + Column(name="parentId", column_type=ColumnType.ENTITYID), + Column(name="benefactorId", column_type=ColumnType.ENTITYID), + Column(name="projectId", column_type=ColumnType.ENTITYID), + Column(name="modifiedOn", column_type=ColumnType.DATE), + Column(name="modifiedBy", column_type=ColumnType.USERID), + Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), + ] + entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, + columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -354,11 +387,27 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create required columns for the EntityView + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + Column(name="createdOn", column_type=ColumnType.DATE), + Column(name="createdBy", column_type=ColumnType.USERID), + Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), + Column(name="type", column_type=ColumnType.STRING, maximum_size=64), + Column(name="parentId", column_type=ColumnType.ENTITYID), + Column(name="benefactorId", column_type=ColumnType.ENTITYID), + Column(name="projectId", column_type=ColumnType.ENTITYID), + Column(name="modifiedOn", column_type=ColumnType.DATE), + Column(name="modifiedBy", column_type=ColumnType.USERID), + Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), + ] + entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, + columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) @@ -556,11 +605,27 @@ async def folder_with_view( schedule_for_cleanup(folder.id) # Create required columns for the EntityView + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + Column(name="createdOn", column_type=ColumnType.DATE), + Column(name="createdBy", column_type=ColumnType.USERID), + Column(name="etag", column_type=ColumnType.STRING, maximum_size=64), + Column(name="type", column_type=ColumnType.STRING, maximum_size=64), + Column(name="parentId", column_type=ColumnType.ENTITYID), + Column(name="benefactorId", column_type=ColumnType.ENTITYID), + Column(name="projectId", column_type=ColumnType.ENTITYID), + Column(name="modifiedOn", column_type=ColumnType.DATE), + Column(name="modifiedBy", column_type=ColumnType.USERID), + Column(name="dataFileHandleId", column_type=ColumnType.FILEHANDLEID), + ] + entity_view = await EntityView( name=str(uuid.uuid4()), parent_id=project_model.id, scope_ids=[folder.id], view_type_mask=ViewTypeMask.FILE.value, + columns=columns, ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) From 64b5e57dfe81b1f635f6712e6b1ae12049c34d34 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 7 May 2026 15:02:44 -0400 Subject: [PATCH 07/10] fix unused imports and type hints --- .../integration/synapseclient/models/async/test_grid_async.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index 90cb1c054..70d390661 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -9,7 +9,6 @@ import pytest from synapseclient import Synapse -from synapseclient.extensions.curator.file_based_metadata_task import EntityView from synapseclient.models import ( EntityView, Folder, @@ -36,7 +35,7 @@ async def entity_view( project_model: Project, syn: Synapse, schedule_for_cleanup: Callable[..., None], - ) -> tuple[Folder, EntityView]: + ) -> EntityView: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( @@ -232,7 +231,6 @@ async def test_synchronize_grid_async( grid = await created_grid.synchronize_async(synapse_client=self.syn) assert grid.source_entity_id == entity_view.id assert grid.session_id == created_grid.session_id - assert grid.source_entity_id == entity_view.id async def test_import_csv_to_grid_session_async( self, From f1d4046f612e4a91e121e2b9930319bb9823ce30 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 7 May 2026 15:03:09 -0400 Subject: [PATCH 08/10] restore decorator; use decorator pattern --- synapseclient/models/curation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 0a4faa06e..726e3e9b9 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -2291,6 +2291,9 @@ async def main(): session_id=self.session_id, synapse_client=synapse_client ) + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Grid_ImportCsv: ID: {self.session_id}" + ) async def import_csv_async( self, path: str, @@ -2537,6 +2540,9 @@ async def main(): synapse_client=synapse_client, ) + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Grid_Synchronize: ID: {self.session_id}" + ) async def synchronize_async( self, *, timeout: int = 120, synapse_client: Optional[Synapse] = None ) -> "Grid": @@ -2586,8 +2592,6 @@ async def main(): if not self.session_id: raise ValueError("session_id is required to synchronize a GridSession") - trace.get_current_span().set_attributes({"synapse.session_id": self.session_id}) - request = SynchronizeGridRequest(session_id=self.session_id) result = await request.send_job_and_wait_async( timeout=timeout, synapse_client=synapse_client From b90da0ed08d79d57744ba53262e15f18c7cf70f2 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 7 May 2026 15:48:37 -0400 Subject: [PATCH 09/10] update to use grid_session_id and make it required --- synapseclient/models/curation.py | 6 +++--- .../models/async/unit_test_curation_async.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index 726e3e9b9..6e0aedfcf 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -1322,7 +1322,7 @@ class SynchronizeGridRequest(AsynchronousCommunicator): The response is modeled from: """ - session_id: str = "" + grid_session_id: str """The ID of the grid session to synchronize.""" concrete_type: str = field(default=SYNCHRONIZE_GRID_REQUEST) @@ -1355,7 +1355,7 @@ def to_synapse_request(self) -> Dict[str, Any]: """ return { "concreteType": self.concrete_type, - "gridSessionId": self.session_id, + "gridSessionId": self.grid_session_id, } @@ -2592,7 +2592,7 @@ async def main(): if not self.session_id: raise ValueError("session_id is required to synchronize a GridSession") - request = SynchronizeGridRequest(session_id=self.session_id) + request = SynchronizeGridRequest(grid_session_id=self.session_id) result = await request.send_job_and_wait_async( timeout=timeout, synapse_client=synapse_client ) diff --git a/tests/unit/synapseclient/models/async/unit_test_curation_async.py b/tests/unit/synapseclient/models/async/unit_test_curation_async.py index 198dc3c0f..1e25c0aa7 100644 --- a/tests/unit/synapseclient/models/async/unit_test_curation_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_curation_async.py @@ -1524,7 +1524,7 @@ class TestSynchronizeGridRequest: def test_to_synapse_request(self) -> None: # GIVEN a SynchronizeGridRequest with all fields set sync_req = SynchronizeGridRequest( - session_id=SESSION_ID, + grid_session_id=SESSION_ID, ) # WHEN I convert it to a synapse request @@ -1544,10 +1544,10 @@ def test_fill_from_dict(self) -> None: } # WHEN I fill a SynchronizeGridRequest from the response - sync_req = SynchronizeGridRequest(session_id=SESSION_ID) + sync_req = SynchronizeGridRequest(grid_session_id=SESSION_ID) response = sync_req.fill_from_dict(raw_response) assert "test_error" in response.error_messages - assert response.session_id == SESSION_ID + assert response.grid_session_id == SESSION_ID class TestSynchronizeGrid: @@ -1568,7 +1568,7 @@ async def test_synchronize_grid_async_empty_error(self) -> None: # GIVEN a Grid with a session_id grid = Grid(session_id=SESSION_ID) mock_sync_response = SynchronizeGridRequest( - session_id=SESSION_ID, + grid_session_id=SESSION_ID, error_messages=[], ) @@ -1587,7 +1587,7 @@ async def test_synchronize_grid_async_with_errors(self) -> None: # GIVEN a Grid with a session_id grid = Grid(session_id=SESSION_ID) mock_sync_response = SynchronizeGridRequest( - session_id=SESSION_ID, + grid_session_id=SESSION_ID, error_messages=["sync_error_1", "sync_error_2"], ) From ab86a44b370a18a1c0a249adf705ddcabdb77d46 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 8 May 2026 13:50:47 -0400 Subject: [PATCH 10/10] make integration test better --- .../models/async/test_grid_async.py | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index 70d390661..75b297333 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -9,16 +9,20 @@ import pytest from synapseclient import Synapse +from synapseclient.core.utils import make_bogus_data_file from synapseclient.models import ( EntityView, + File, Folder, Grid, Project, RecordSet, ViewTypeMask, + query_async, ) from synapseclient.models.table_components import Query -from tests.integration import ASYNC_JOB_TIMEOUT_SEC +from tests.integration import ASYNC_JOB_TIMEOUT_SEC, QUERY_TIMEOUT_SEC +from tests.integration.helpers import wait_for_condition class TestGridAsync: @@ -29,13 +33,13 @@ def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: self.syn = syn self.schedule_for_cleanup = schedule_for_cleanup - @pytest.fixture(scope="class") + @pytest.fixture(scope="function") async def entity_view( self, project_model: Project, syn: Synapse, schedule_for_cleanup: Callable[..., None], - ) -> EntityView: + ) -> tuple[Folder, EntityView]: """Create a folder with an associated EntityView for file-based testing.""" # Create a folder folder = await Folder( @@ -52,7 +56,7 @@ async def entity_view( ).store_async(synapse_client=syn) schedule_for_cleanup(entity_view.id) - return entity_view + return folder, entity_view @pytest.fixture(scope="function") async def record_set_fixture(self, project_model: Project) -> RecordSet: @@ -219,18 +223,59 @@ async def test_delete_grid_session_validation_error_async(self) -> None: async def test_synchronize_grid_async( self, - entity_view: EntityView, + entity_view: tuple[Folder, EntityView], ) -> None: + folder, ev = entity_view - # GIVEN: A Grid with a session_id - query = Query(sql=f"SELECT * FROM {entity_view.id}") + # GIVEN: A Grid session created at T0 from an empty EntityView + query = Query(sql=f"SELECT * FROM {ev.id}") grid = Grid(initial_query=query) created_grid = await grid.create_async(synapse_client=self.syn) - # WHEN: Synchronizing the grid session - grid = await created_grid.synchronize_async(synapse_client=self.syn) - assert grid.source_entity_id == entity_view.id - assert grid.session_id == created_grid.session_id + try: + # AND: A file uploaded into the scoped folder + bogus_file = make_bogus_data_file() + self.schedule_for_cleanup(bogus_file) + uploaded_file = await File( + path=bogus_file, + parent_id=folder.id, + ).store_async(synapse_client=self.syn) + self.schedule_for_cleanup(uploaded_file.id) + + # Wait for the EntityView to index the new file + async def file_indexed() -> bool: + df = await query_async( + query=f"SELECT id FROM {ev.id} WHERE id = '{uploaded_file.id}'", + include_row_id_and_row_version=False, + synapse_client=self.syn, + ) + return not df.empty + + await wait_for_condition( + condition_fn=file_indexed, + timeout_seconds=QUERY_TIMEOUT_SEC, + ) + + # WHEN: Synchronizing the same session + synced_grid = await created_grid.synchronize_async(synapse_client=self.syn) + + # THEN: The session ID is unchanged + assert synced_grid.session_id == created_grid.session_id + assert synced_grid.source_entity_id == ev.id + + # AND: The downloaded CSV reflects the newly uploaded file + dest = tempfile.mkdtemp() + self.schedule_for_cleanup(dest) + csv_path = await synced_grid.download_csv_async( + destination=dest, + timeout=ASYNC_JOB_TIMEOUT_SEC, + synapse_client=self.syn, + ) + df = pd.read_csv(csv_path) + assert uploaded_file.id in df["id"].tolist() + finally: + if created_grid.session_id: + await created_grid.delete_async(synapse_client=self.syn) async def test_import_csv_to_grid_session_async( self,