From 1ece9d37b7b23091354e69e553b3e10d435d323d Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 07:46:51 -0700 Subject: [PATCH 01/16] added get grid endpoint and method --- synapseclient/api/curation_services.py | 27 +++++++ synapseclient/models/curation.py | 77 +++++++++++++++++++ .../models/async/test_grid_async.py | 20 +++++ 3 files changed, 124 insertions(+) diff --git a/synapseclient/api/curation_services.py b/synapseclient/api/curation_services.py index 3f13e99f5..f2a59c5b2 100644 --- a/synapseclient/api/curation_services.py +++ b/synapseclient/api/curation_services.py @@ -258,6 +258,33 @@ async def list_grid_sessions( yield item +async def get_grid_session( + session_id: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> dict[str, Any]: + """ + Get a single grid session by its session id. + + https://rest-docs.synapse.org/rest/GET/grid/session/sessionId.html + + Arguments: + session_id: The unique identifier of the grid session to get. + 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: + The GridSession as a dictionary. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + response = await client.rest_get_async(uri=f"/grid/session/{session_id}") + return response + + async def delete_grid_session( session_id: str, *, diff --git a/synapseclient/models/curation.py b/synapseclient/models/curation.py index a38c78229..1152eb832 100644 --- a/synapseclient/models/curation.py +++ b/synapseclient/models/curation.py @@ -34,6 +34,7 @@ get_curation_task_status, get_file_handle, get_file_handle_presigned_url, + get_grid_session, list_curation_tasks, list_grid_sessions, update_curation_task, @@ -2786,6 +2787,37 @@ def create( """ return self + def get(self, *, synapse_client: Optional[Synapse] = None) -> "Grid": + """ + Get a grid session from Synapse by its session_id. + + Arguments: + 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: + The Grid object populated with the session data from Synapse. + + Raises: + ValueError: If session_id is not provided. + + Example: Get a grid session by its session id +   + + ```python + from synapseclient import Synapse + from synapseclient.models import Grid + + syn = Synapse() + syn.login() + + grid = Grid(session_id="abc-123-def").get() + print(f"Source entity: {grid.source_entity_id}") + ``` + """ + return self + def export_to_record_set( self, *, timeout: int = 120, synapse_client: Optional[Synapse] = None ) -> "Grid": @@ -3306,6 +3338,51 @@ async def main(): return self + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Grid_Get: ID: {self.session_id}" + ) + async def get_async(self, *, synapse_client: Optional[Synapse] = None) -> "Grid": + """ + Get a grid session from Synapse by its session_id. + + Arguments: + 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: + The Grid object populated with the session data from Synapse. + + Raises: + ValueError: If session_id is not provided. + + Example: Get a grid session by its session id asynchronously +   + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Grid + + syn = Synapse() + syn.login() + + async def main(): + grid = await Grid(session_id="abc-123-def").get_async() + print(f"Source entity: {grid.source_entity_id}") + + asyncio.run(main()) + ``` + """ + if not self.session_id: + raise ValueError("session_id is required to get a GridSession") + + response = await get_grid_session( + session_id=self.session_id, synapse_client=synapse_client + ) + self.fill_from_dict(response) + return self + async def export_to_record_set_async( self, *, timeout: int = 120, synapse_client: Optional[Synapse] = None ) -> "Grid": diff --git a/tests/integration/synapseclient/models/async/test_grid_async.py b/tests/integration/synapseclient/models/async/test_grid_async.py index b4eb32bf4..83393b538 100644 --- a/tests/integration/synapseclient/models/async/test_grid_async.py +++ b/tests/integration/synapseclient/models/async/test_grid_async.py @@ -141,6 +141,26 @@ async def test_create_and_list_grid_sessions_async( assert our_session.started_by == created_grid.started_by assert our_session.source_entity_id == record_set_fixture.id + async def test_get_grid_session_async(self, record_set_fixture: RecordSet) -> None: + # GIVEN: A grid session created from a record set + created_grid = await Grid(record_set_id=record_set_fixture.id).create_async( + timeout=ASYNC_JOB_TIMEOUT_SEC, synapse_client=self.syn + ) + self.schedule_for_cleanup(created_grid) + assert created_grid.session_id is not None + + # WHEN: Getting the session from Synapse via a fresh Grid instance + fetched_grid = await Grid(session_id=created_grid.session_id).get_async( + synapse_client=self.syn + ) + + # THEN: The same instance is returned, populated from the server + assert fetched_grid.session_id == created_grid.session_id + assert fetched_grid.started_by == created_grid.started_by + assert fetched_grid.started_on == created_grid.started_on + assert fetched_grid.etag == created_grid.etag + assert fetched_grid.source_entity_id == record_set_fixture.id + async def test_create_grid_session_with_authorization_mode_async( self, record_set_fixture: RecordSet ) -> None: From 2767cc6d604f486752b2d8781c07dba26013f954 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 07:47:10 -0700 Subject: [PATCH 02/16] added get grid endpoint and method --- synapseclient/api/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index c55e43410..f733b0b37 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -22,6 +22,7 @@ delete_grid_session, get_curation_task, get_curation_task_status, + get_grid_session, list_curation_tasks, list_grid_sessions, update_curation_task, @@ -335,6 +336,7 @@ "delete_grid_session", "get_curation_task", "get_curation_task_status", + "get_grid_session", "list_curation_tasks", "list_grid_sessions", "update_curation_task", From c72ea4a09f543f48433c39f7505f3099e893478f Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 08:03:56 -0700 Subject: [PATCH 03/16] add new high level function for creating grids --- synapseclient/extensions/curator/__init__.py | 2 + .../extensions/curator/get_curator_grid.py | 142 ++++++++++++++++ .../extensions/unit_test_get_curator_grid.py | 156 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 synapseclient/extensions/curator/get_curator_grid.py create mode 100644 tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py diff --git a/synapseclient/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index ba6bdfee9..a52e55858 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -5,6 +5,7 @@ """ from .file_based_metadata_task import create_file_based_metadata_task +from .get_curator_grid import get_curator_grid from .record_based_metadata_task import create_record_based_metadata_task from .schema_generation import generate_jsonld, generate_jsonschema from .schema_management import ( @@ -16,6 +17,7 @@ from .schema_registry import query_schema_registry __all__ = [ + "get_curator_grid", "create_file_based_metadata_task", "create_record_based_metadata_task", "query_schema_registry", diff --git a/synapseclient/extensions/curator/get_curator_grid.py b/synapseclient/extensions/curator/get_curator_grid.py new file mode 100644 index 000000000..cebb0ffdb --- /dev/null +++ b/synapseclient/extensions/curator/get_curator_grid.py @@ -0,0 +1,142 @@ +""" +High-level helper for getting or creating a Grid for a CurationTask. + +This module provides a single library function, get_curator_grid, that a data +contributor can call after being assigned a CurationTask by a data manager. It +returns the Grid that is currently attached to the task, creating and attaching +a new grid session first if none exists yet. +""" + +from synapseclient import Synapse # type: ignore +from synapseclient.models import ( # type: ignore + CurationTask, + Grid, + GridExecutionDetails, +) + + +def get_curator_grid( + task_id: int, + *, + owner_principal_id: int | None = None, + timeout: int = 120, + synapse_client: Synapse | None = None, +) -> Grid: + """ + Get the Grid attached to a CurationTask, creating one if none exists. + + This is the high-level entry point for a data contributor who has been + assigned a CurationTask by a data manager and wants to start curating in a + grid. It performs the following steps: + + 1. Gets the CurationTask from Synapse. + 2. Checks whether a grid session is already attached to the task. + 3. If a session is attached, gets that Grid from Synapse and returns it. + 4. If no session is attached, creates a new grid session, attaches it to the + task, and returns the new Grid. + + When a new grid session is created it uses the task's suggested authorization + mode (set on the task by the data manager). SESSION_OWNER limits access to + the session owner and their team, while SOURCE_BENEFACTOR extends access to + anyone with EDIT permission on the data being curated. When the task has no + suggested authorization mode, the caller becomes the session owner. + + Example: Get or create the grid for an assigned curation task +   + A data contributor opens the grid for a task they were assigned. The + first call creates the grid session and attaches it to the task; + subsequent calls return that same grid. + ```python + import synapseclient + from synapseclient.extensions.curator import get_curator_grid + + syn = synapseclient.Synapse() + syn.login() + + grid = get_curator_grid(task_id=123) + print(f"Grid session: {grid.session_id}") + ``` + + Arguments: + task_id: The unique identifier of the CurationTask to get or create a + grid for. + owner_principal_id: The principal ID (user or team) that will own a newly + created grid session. Ignored when a session is already attached to + the task. When not provided, the caller becomes the owner (subject to + the task's suggested authorization mode). + timeout: Seconds to wait for the grid creation job when a new session must + be created. 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: + The Grid attached to the CurationTask. + + Raises: + ValueError: If the task's status does not have grid execution details, + or the task's properties are of an unsupported type for grid + creation. + SynapseHTTPError: If there are issues communicating with Synapse. + """ + client = Synapse.get_client(synapse_client=synapse_client) + + # Step 1: get the CurationTask. + client.logger.info(f"Attempting to get CurationTask {task_id}.") + try: + task = CurationTask(task_id=task_id).get(synapse_client=client) + except Exception as e: + client.logger.exception(f"Error getting CurationTask {task_id}") + raise e + client.logger.info(f"Got CurationTask {task_id}.") + + # Step 2: check whether a grid session is already attached to the task. + client.logger.info(f"Attempting to get status for CurationTask {task_id}.") + try: + status = task.get_status(synapse_client=client) + except Exception as e: + client.logger.exception(f"Error getting status for CurationTask {task_id}") + raise e + client.logger.info(f"Got status for CurationTask {task_id}.") + + if not isinstance(status.execution_details, GridExecutionDetails): + raise ValueError( + f"CurationTask {task_id} does not have grid execution details " + "(got " + f"{type(status.execution_details).__name__}); " + "cannot get or create a grid for it." + ) + active_session_id = status.execution_details.active_session_id + + # Step 3: a session is attached, so get that Grid from Synapse and return it. + if active_session_id: + client.logger.info( + f"CurationTask {task_id} already has grid session {active_session_id}; " + "attempting to get the existing grid." + ) + try: + grid = Grid(session_id=active_session_id).get(synapse_client=client) + except Exception as e: + client.logger.exception(f"Error getting grid session {active_session_id}") + raise e + client.logger.info(f"Got grid session {active_session_id}.") + return grid + + # Step 4: no session attached, so create one, attach it, and return it. + client.logger.info( + f"CurationTask {task_id} has no attached grid session; " + "attempting to create one." + ) + try: + grid = task.create_grid_session( + owner_principal_id=owner_principal_id, + timeout=timeout, + synapse_client=client, + ) + except Exception as e: + client.logger.exception( + f"Error creating grid session for CurationTask {task_id}" + ) + raise e + client.logger.info(f"Created grid session for CurationTask {task_id}.") + return grid diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py new file mode 100644 index 000000000..95175862c --- /dev/null +++ b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py @@ -0,0 +1,156 @@ +""" +Unit tests for synapseclient.extensions.curator.get_curator_grid. +""" + +import sys +from unittest.mock import Mock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.extensions.curator.get_curator_grid import get_curator_grid +from synapseclient.models.curation import ( + CurationTask, + CurationTaskStatus, + GridExecutionDetails, +) + +# The `get_curator_grid` function is re-exported from the curator package, which +# shadows the `get_curator_grid` submodule attribute. Grab the module object from +# sys.modules so patch.object targets the module's names, not the function. +grid_module = sys.modules["synapseclient.extensions.curator.get_curator_grid"] + + +def _build_mock_client(): + """A Synapse-spec mock with a usable logger.""" + client = Mock(spec=Synapse) + client.logger = Mock() + return client + + +def _build_mock_task(status): + """Build a mock CurationTask whose accessors are wired up.""" + task = Mock(spec=CurationTask) + task.get_status.return_value = status + return task + + +class TestGetCuratorGrid: + """Test cases for the get_curator_grid function.""" + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_creates_grid_when_no_session_attached( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When the task has grid execution details but no active session id, + a new grid session is created (with the given owner and timeout) and + returned, and the existing-grid lookup is not attempted.""" + # GIVEN: A task whose status has grid execution details but no active + # session id set + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus( + execution_details=GridExecutionDetails(active_session_id=None) + ) + task = _build_mock_task(status) + created_grid = Mock(session_id="new-session") + task.create_grid_session.return_value = created_grid + mock_curation_task_cls.return_value.get.return_value = task + + # WHEN: Getting the curator grid for the task + result = get_curator_grid( + task_id=123, + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + + # THEN: A new grid session is created with the given owner and timeout + # and returned + assert result is created_grid + task.create_grid_session.assert_called_once_with( + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + # AND: The existing-grid lookup is not attempted + mock_grid_cls.return_value.get.assert_not_called() + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_gets_existing_grid_by_session_id( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When the task already has an active grid session, that grid is + fetched directly by its session id and returned, and no new grid + session is created.""" + # GIVEN: A task whose status has an active grid session id + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus( + execution_details=GridExecutionDetails(active_session_id="abc-123") + ) + task = _build_mock_task(status) + mock_curation_task_cls.return_value.get.return_value = task + + fetched_grid = Mock(session_id="abc-123") + mock_grid_cls.return_value.get.return_value = fetched_grid + + # WHEN: Getting the curator grid for the task + result = get_curator_grid(task_id=123, synapse_client=client) + + # THEN: The existing grid is fetched directly by its session id and + # returned, and no new grid session is created + assert result is fetched_grid + mock_grid_cls.assert_called_once_with(session_id="abc-123") + mock_grid_cls.return_value.get.assert_called_once_with(synapse_client=client) + task.create_grid_session.assert_not_called() + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_error_is_logged_and_reraised( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When a Synapse call fails, the exception is logged via + logger.exception and re-raised to the caller.""" + # GIVEN: A task whose get call fails with an error + client = _build_mock_client() + mock_synapse.get_client.return_value = client + boom = RuntimeError("boom") + mock_curation_task_cls.return_value.get.side_effect = boom + + # WHEN: Getting the curator grid for the task + # THEN: The error is re-raised to the caller + with pytest.raises(RuntimeError, match="boom"): + get_curator_grid(task_id=123, synapse_client=client) + + # AND: The error is logged via logger.exception + client.logger.exception.assert_called_once() + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_raises_when_status_has_no_grid_execution_details( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When the task status has no grid execution details, a ValueError is + raised and neither the grid lookup nor grid creation is attempted.""" + # GIVEN: A task whose status has no grid execution details + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus(execution_details=None) + task = _build_mock_task(status) + mock_curation_task_cls.return_value.get.return_value = task + + # WHEN: Getting the curator grid for the task + # THEN: A ValueError is raised + with pytest.raises(ValueError, match="grid execution details"): + get_curator_grid(task_id=123, synapse_client=client) + + # AND: Neither the grid lookup nor grid creation is attempted + mock_grid_cls.return_value.get.assert_not_called() + task.create_grid_session.assert_not_called() From 3d52d8cfae1bddd7542c10cb67142066ad5fefe0 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 08:12:02 -0700 Subject: [PATCH 04/16] removetry/except blocks --- .../extensions/curator/get_curator_grid.py | 34 +++++-------------- .../extensions/unit_test_get_curator_grid.py | 14 ++++---- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/synapseclient/extensions/curator/get_curator_grid.py b/synapseclient/extensions/curator/get_curator_grid.py index cebb0ffdb..14c344042 100644 --- a/synapseclient/extensions/curator/get_curator_grid.py +++ b/synapseclient/extensions/curator/get_curator_grid.py @@ -83,20 +83,12 @@ def get_curator_grid( # Step 1: get the CurationTask. client.logger.info(f"Attempting to get CurationTask {task_id}.") - try: - task = CurationTask(task_id=task_id).get(synapse_client=client) - except Exception as e: - client.logger.exception(f"Error getting CurationTask {task_id}") - raise e + task = CurationTask(task_id=task_id).get(synapse_client=client) client.logger.info(f"Got CurationTask {task_id}.") # Step 2: check whether a grid session is already attached to the task. client.logger.info(f"Attempting to get status for CurationTask {task_id}.") - try: - status = task.get_status(synapse_client=client) - except Exception as e: - client.logger.exception(f"Error getting status for CurationTask {task_id}") - raise e + status = task.get_status(synapse_client=client) client.logger.info(f"Got status for CurationTask {task_id}.") if not isinstance(status.execution_details, GridExecutionDetails): @@ -114,11 +106,7 @@ def get_curator_grid( f"CurationTask {task_id} already has grid session {active_session_id}; " "attempting to get the existing grid." ) - try: - grid = Grid(session_id=active_session_id).get(synapse_client=client) - except Exception as e: - client.logger.exception(f"Error getting grid session {active_session_id}") - raise e + grid = Grid(session_id=active_session_id).get(synapse_client=client) client.logger.info(f"Got grid session {active_session_id}.") return grid @@ -127,16 +115,10 @@ def get_curator_grid( f"CurationTask {task_id} has no attached grid session; " "attempting to create one." ) - try: - grid = task.create_grid_session( - owner_principal_id=owner_principal_id, - timeout=timeout, - synapse_client=client, - ) - except Exception as e: - client.logger.exception( - f"Error creating grid session for CurationTask {task_id}" - ) - raise e + grid = task.create_grid_session( + owner_principal_id=owner_principal_id, + timeout=timeout, + synapse_client=client, + ) client.logger.info(f"Created grid session for CurationTask {task_id}.") return grid diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py index 95175862c..8f8f107a5 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py @@ -112,11 +112,11 @@ def test_gets_existing_grid_by_session_id( @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_error_is_logged_and_reraised( + def test_error_propagates_to_caller( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): - """When a Synapse call fails, the exception is logged via - logger.exception and re-raised to the caller.""" + """When a Synapse call fails, the exception propagates to the caller + with its original traceback.""" # GIVEN: A task whose get call fails with an error client = _build_mock_client() mock_synapse.get_client.return_value = client @@ -124,12 +124,12 @@ def test_error_is_logged_and_reraised( mock_curation_task_cls.return_value.get.side_effect = boom # WHEN: Getting the curator grid for the task - # THEN: The error is re-raised to the caller - with pytest.raises(RuntimeError, match="boom"): + # THEN: The error propagates unchanged to the caller + with pytest.raises(RuntimeError, match="boom") as exc_info: get_curator_grid(task_id=123, synapse_client=client) - # AND: The error is logged via logger.exception - client.logger.exception.assert_called_once() + # AND: The propagated exception is the original one + assert exc_info.value is boom @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") From b14f9b680b5117e60467ccb28f8b96395b96b28c Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 08:30:31 -0700 Subject: [PATCH 05/16] updated docs to use new function --- .../extensions/curator/metadata_curation.md | 32 ++++++++----------- docs/reference/experimental/async/curator.md | 1 + docs/reference/experimental/sync/curator.md | 1 + 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index e0d4d8ca3..a4ac4bb38 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -239,35 +239,27 @@ Validation results are only generated when a Grid session is exported back to th Users can access the curation task through the Synapse web interface, enter/edit data in the Grid, and click the export button. This automatically generates validation results. -**Option B: Programmatically create and export a Grid session** +**Option B: Programmatically get a Grid session** + +When you have been assigned a CurationTask by a data manager, [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] is the simplest way to open its Grid. Given only the task's ID, it returns the Grid already attached to the task, or — if none is attached yet — creates a new grid session, attaches it to the task, and returns it. This means the first call starts the session and every subsequent call returns that same session. + +A new grid session is created using the task's suggested authorization mode (see [Controlling who can access the grid session](#controlling-who-can-access-the-grid-session)), so you do not need to specify it here. ```python -from synapseclient import Synapse -from synapseclient.models import RecordSet -from synapseclient.models.curation import Grid +import synapseclient +from synapseclient.extensions.curator import get_curator_grid -syn = Synapse() +syn = synapseclient.Synapse() syn.login() -# Get your RecordSet (must have a schema bound) -record_set = RecordSet(id="syn987654321").get() +# Get the Grid for a task you were assigned, creating one if needed +grid = get_curator_grid(task_id=123) +print(f"Grid session: {grid.session_id}") -# Create a Grid session from the RecordSet -grid = Grid(record_set_id=record_set.id).create() - -# At this point, users can interact with the Grid (either programmatically or via web UI) # When ready to save changes and generate validation results, export back to RecordSet grid.export_to_record_set() - -# Clean up the Grid session -grid.delete() - -# Re-fetch the RecordSet to get the updated validation_file_handle_id -record_set = RecordSet(id=record_set.id).get() ``` -**Important**: The `validation_file_handle_id` attribute is only populated after a Grid export operation. Until then, `get_detailed_validation_results()` will return `None`. - ### Getting detailed validation results After exporting from a Grid session with a bound schema, Synapse automatically validates each row against the schema and generates a detailed validation report. Here's how to retrieve and analyze those results: @@ -544,8 +536,10 @@ print(f"Task {TASK_ID} state is now: {status.state}") - [query_schema_registry][synapseclient.extensions.curator.query_schema_registry] - Search for schemas in the registry - [create_record_based_metadata_task][synapseclient.extensions.curator.create_record_based_metadata_task] - Create RecordSet-based curation workflows - [create_file_based_metadata_task][synapseclient.extensions.curator.create_file_based_metadata_task] - Create EntityView-based curation workflows +- [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] - Get or create the Grid attached to a curation task - [RecordSet.get_detailed_validation_results][synapseclient.models.RecordSet.get_detailed_validation_results] - Get detailed validation results for RecordSet data - [Grid.create][synapseclient.models.curation.Grid.create] - Create a Grid session from a RecordSet or EntityView +- [Grid.get][synapseclient.models.curation.Grid.get] - Get a Grid session from Synapse by its session id - [Grid.export_to_record_set][synapseclient.models.curation.Grid.export_to_record_set] - Export Grid data back to RecordSet and generate validation results - [Folder.bind_schema][synapseclient.models.Folder.bind_schema] - Bind schemas to folders - [Folder.validate_schema][synapseclient.models.Folder.validate_schema] - Validate folder schema compliance diff --git a/docs/reference/experimental/async/curator.md b/docs/reference/experimental/async/curator.md index 6908bfa9f..1b844845b 100644 --- a/docs/reference/experimental/async/curator.md +++ b/docs/reference/experimental/async/curator.md @@ -66,6 +66,7 @@ at your own risk. inherited_members: true members: - create_async + - get_async - export_to_record_set_async - synchronize_async - download_csv_async diff --git a/docs/reference/experimental/sync/curator.md b/docs/reference/experimental/sync/curator.md index ad5a3eab0..b9613bc58 100644 --- a/docs/reference/experimental/sync/curator.md +++ b/docs/reference/experimental/sync/curator.md @@ -66,6 +66,7 @@ at your own risk. inherited_members: true members: - create + - get - export_to_record_set - synchronize - download_csv From 88589dfd03c78cbb8a69c74ea54759f747864516 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 08:30:44 -0700 Subject: [PATCH 06/16] rename file --- synapseclient/extensions/curator/__init__.py | 2 +- .../curator/{get_curator_grid.py => get_grid.py} | 0 .../extensions/unit_test_get_curator_grid.py | 9 +++------ 3 files changed, 4 insertions(+), 7 deletions(-) rename synapseclient/extensions/curator/{get_curator_grid.py => get_grid.py} (100%) diff --git a/synapseclient/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index a52e55858..0a73f3a24 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -5,7 +5,7 @@ """ from .file_based_metadata_task import create_file_based_metadata_task -from .get_curator_grid import get_curator_grid +from .get_grid import get_curator_grid from .record_based_metadata_task import create_record_based_metadata_task from .schema_generation import generate_jsonld, generate_jsonschema from .schema_management import ( diff --git a/synapseclient/extensions/curator/get_curator_grid.py b/synapseclient/extensions/curator/get_grid.py similarity index 100% rename from synapseclient/extensions/curator/get_curator_grid.py rename to synapseclient/extensions/curator/get_grid.py diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py index 8f8f107a5..2605a4bc0 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py @@ -1,5 +1,5 @@ """ -Unit tests for synapseclient.extensions.curator.get_curator_grid. +Unit tests for synapseclient.extensions.curator.get_grid. """ import sys @@ -8,17 +8,14 @@ import pytest from synapseclient import Synapse -from synapseclient.extensions.curator.get_curator_grid import get_curator_grid +from synapseclient.extensions.curator.get_grid import get_curator_grid from synapseclient.models.curation import ( CurationTask, CurationTaskStatus, GridExecutionDetails, ) -# The `get_curator_grid` function is re-exported from the curator package, which -# shadows the `get_curator_grid` submodule attribute. Grab the module object from -# sys.modules so patch.object targets the module's names, not the function. -grid_module = sys.modules["synapseclient.extensions.curator.get_curator_grid"] +grid_module = sys.modules["synapseclient.extensions.curator.get_grid"] def _build_mock_client(): From 0bf43d2b3208daa71ea69728de998b8a87769d9e Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 08:40:52 -0700 Subject: [PATCH 07/16] revert doc file --- .../extensions/curator/metadata_curation.md | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_curation.md b/docs/guides/extensions/curator/metadata_curation.md index a4ac4bb38..e0d4d8ca3 100644 --- a/docs/guides/extensions/curator/metadata_curation.md +++ b/docs/guides/extensions/curator/metadata_curation.md @@ -239,27 +239,35 @@ Validation results are only generated when a Grid session is exported back to th Users can access the curation task through the Synapse web interface, enter/edit data in the Grid, and click the export button. This automatically generates validation results. -**Option B: Programmatically get a Grid session** - -When you have been assigned a CurationTask by a data manager, [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] is the simplest way to open its Grid. Given only the task's ID, it returns the Grid already attached to the task, or — if none is attached yet — creates a new grid session, attaches it to the task, and returns it. This means the first call starts the session and every subsequent call returns that same session. - -A new grid session is created using the task's suggested authorization mode (see [Controlling who can access the grid session](#controlling-who-can-access-the-grid-session)), so you do not need to specify it here. +**Option B: Programmatically create and export a Grid session** ```python -import synapseclient -from synapseclient.extensions.curator import get_curator_grid +from synapseclient import Synapse +from synapseclient.models import RecordSet +from synapseclient.models.curation import Grid -syn = synapseclient.Synapse() +syn = Synapse() syn.login() -# Get the Grid for a task you were assigned, creating one if needed -grid = get_curator_grid(task_id=123) -print(f"Grid session: {grid.session_id}") +# Get your RecordSet (must have a schema bound) +record_set = RecordSet(id="syn987654321").get() +# Create a Grid session from the RecordSet +grid = Grid(record_set_id=record_set.id).create() + +# At this point, users can interact with the Grid (either programmatically or via web UI) # When ready to save changes and generate validation results, export back to RecordSet grid.export_to_record_set() + +# Clean up the Grid session +grid.delete() + +# Re-fetch the RecordSet to get the updated validation_file_handle_id +record_set = RecordSet(id=record_set.id).get() ``` +**Important**: The `validation_file_handle_id` attribute is only populated after a Grid export operation. Until then, `get_detailed_validation_results()` will return `None`. + ### Getting detailed validation results After exporting from a Grid session with a bound schema, Synapse automatically validates each row against the schema and generates a detailed validation report. Here's how to retrieve and analyze those results: @@ -536,10 +544,8 @@ print(f"Task {TASK_ID} state is now: {status.state}") - [query_schema_registry][synapseclient.extensions.curator.query_schema_registry] - Search for schemas in the registry - [create_record_based_metadata_task][synapseclient.extensions.curator.create_record_based_metadata_task] - Create RecordSet-based curation workflows - [create_file_based_metadata_task][synapseclient.extensions.curator.create_file_based_metadata_task] - Create EntityView-based curation workflows -- [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] - Get or create the Grid attached to a curation task - [RecordSet.get_detailed_validation_results][synapseclient.models.RecordSet.get_detailed_validation_results] - Get detailed validation results for RecordSet data - [Grid.create][synapseclient.models.curation.Grid.create] - Create a Grid session from a RecordSet or EntityView -- [Grid.get][synapseclient.models.curation.Grid.get] - Get a Grid session from Synapse by its session id - [Grid.export_to_record_set][synapseclient.models.curation.Grid.export_to_record_set] - Export Grid data back to RecordSet and generate validation results - [Folder.bind_schema][synapseclient.models.Folder.bind_schema] - Bind schemas to folders - [Folder.validate_schema][synapseclient.models.Folder.validate_schema] - Validate folder schema compliance From 7abf9a493923d5b94264b256571b403d264de3e4 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 09:13:09 -0700 Subject: [PATCH 08/16] fix bug when task has no execution details --- synapseclient/extensions/curator/get_grid.py | 17 +++--- .../extensions/unit_test_get_curator_grid.py | 52 +++++++++++++++++-- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/synapseclient/extensions/curator/get_grid.py b/synapseclient/extensions/curator/get_grid.py index 14c344042..f1fd20b65 100644 --- a/synapseclient/extensions/curator/get_grid.py +++ b/synapseclient/extensions/curator/get_grid.py @@ -74,9 +74,8 @@ def get_curator_grid( The Grid attached to the CurationTask. Raises: - ValueError: If the task's status does not have grid execution details, - or the task's properties are of an unsupported type for grid - creation. + ValueError: If the task's status has non-grid execution details, or the + task's properties are of an unsupported type for grid creation. SynapseHTTPError: If there are issues communicating with Synapse. """ client = Synapse.get_client(synapse_client=synapse_client) @@ -91,14 +90,16 @@ def get_curator_grid( status = task.get_status(synapse_client=client) client.logger.info(f"Got status for CurationTask {task_id}.") - if not isinstance(status.execution_details, GridExecutionDetails): + execution_details = status.execution_details + active_session_id = None + if isinstance(execution_details, GridExecutionDetails): + active_session_id = execution_details.active_session_id + elif execution_details is not None: raise ValueError( - f"CurationTask {task_id} does not have grid execution details " - "(got " - f"{type(status.execution_details).__name__}); " + f"CurationTask {task_id} has non-grid execution details " + f"(got {type(execution_details).__name__}); " "cannot get or create a grid for it." ) - active_session_id = status.execution_details.active_session_id # Step 3: a session is attached, so get that Grid from Synapse and return it. if active_session_id: diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py index 2605a4bc0..2ed945db3 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py @@ -131,21 +131,63 @@ def test_error_propagates_to_caller( @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_raises_when_status_has_no_grid_execution_details( + def test_creates_grid_when_execution_details_is_none( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): - """When the task status has no grid execution details, a ValueError is - raised and neither the grid lookup nor grid creation is attempted.""" - # GIVEN: A task whose status has no grid execution details + """When the task status has no execution details at all (a task that has + never had a grid), a new grid session is created and returned, and the + existing-grid lookup is not attempted.""" + # GIVEN: A task whose status has no execution details client = _build_mock_client() mock_synapse.get_client.return_value = client status = CurationTaskStatus(execution_details=None) task = _build_mock_task(status) + created_grid = Mock(session_id="new-session") + task.create_grid_session.return_value = created_grid + mock_curation_task_cls.return_value.get.return_value = task + + # WHEN: Getting the curator grid for the task + result = get_curator_grid( + task_id=123, + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + + # THEN: A new grid session is created with the given owner and timeout + # and returned + assert result is created_grid + task.create_grid_session.assert_called_once_with( + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + # AND: The existing-grid lookup is not attempted + mock_grid_cls.return_value.get.assert_not_called() + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_raises_when_status_has_non_grid_execution_details( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When the task status carries a non-grid execution details type, a + ValueError is raised and neither the grid lookup nor grid creation is + attempted.""" + + # GIVEN: A task whose status has a non-grid execution details type + class _NonGridExecutionDetails: + pass + + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus(execution_details=_NonGridExecutionDetails()) + task = _build_mock_task(status) mock_curation_task_cls.return_value.get.return_value = task # WHEN: Getting the curator grid for the task # THEN: A ValueError is raised - with pytest.raises(ValueError, match="grid execution details"): + with pytest.raises(ValueError, match="non-grid execution details"): get_curator_grid(task_id=123, synapse_client=client) # AND: Neither the grid lookup nor grid creation is attempted From d397d90647bc6ce3b43c2d3df6142ac1f4792e42 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 09:24:04 -0700 Subject: [PATCH 09/16] added use-case where thr taks points to a Grid that no longer exists --- synapseclient/extensions/curator/get_grid.py | 30 +++++--- .../extensions/unit_test_get_curator_grid.py | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/synapseclient/extensions/curator/get_grid.py b/synapseclient/extensions/curator/get_grid.py index f1fd20b65..2c95f7491 100644 --- a/synapseclient/extensions/curator/get_grid.py +++ b/synapseclient/extensions/curator/get_grid.py @@ -8,6 +8,7 @@ """ from synapseclient import Synapse # type: ignore +from synapseclient.core.exceptions import SynapseHTTPError # type: ignore from synapseclient.models import ( # type: ignore CurationTask, Grid, @@ -31,7 +32,9 @@ def get_curator_grid( 1. Gets the CurationTask from Synapse. 2. Checks whether a grid session is already attached to the task. - 3. If a session is attached, gets that Grid from Synapse and returns it. + 3. If a session is attached, gets that Grid from Synapse and returns it. If + the attached session no longer exists (it was deleted out from under the + task), a new grid session is created and attached instead. 4. If no session is attached, creates a new grid session, attaches it to the task, and returns the new Grid. @@ -76,7 +79,9 @@ def get_curator_grid( Raises: ValueError: If the task's status has non-grid execution details, or the task's properties are of an unsupported type for grid creation. - SynapseHTTPError: If there are issues communicating with Synapse. + SynapseHTTPError: If there are issues communicating with Synapse. A 404 + on the attached session is handled by creating a new one; any other + HTTP error propagates. """ client = Synapse.get_client(synapse_client=synapse_client) @@ -101,17 +106,26 @@ def get_curator_grid( "cannot get or create a grid for it." ) - # Step 3: a session is attached, so get that Grid from Synapse and return it. + # Step 3: a session is attached, so try to get that Grid and return it. if active_session_id: client.logger.info( f"CurationTask {task_id} already has grid session {active_session_id}; " "attempting to get the existing grid." ) - grid = Grid(session_id=active_session_id).get(synapse_client=client) - client.logger.info(f"Got grid session {active_session_id}.") - return grid - - # Step 4: no session attached, so create one, attach it, and return it. + try: + grid = Grid(session_id=active_session_id).get(synapse_client=client) + client.logger.info(f"Got grid session {active_session_id}.") + return grid + except SynapseHTTPError as exc: + # The task points at a session that no longer exists + if exc.response is None or exc.response.status_code != 404: + raise + client.logger.info( + f"Linked grid session {active_session_id} no longer exists; " + "creating a new one." + ) + + # Step 4: no usable session attached, so create one, attach it, and return it. client.logger.info( f"CurationTask {task_id} has no attached grid session; " "attempting to create one." diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py index 2ed945db3..9a831af22 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py @@ -8,6 +8,7 @@ import pytest from synapseclient import Synapse +from synapseclient.core.exceptions import SynapseHTTPError from synapseclient.extensions.curator.get_grid import get_curator_grid from synapseclient.models.curation import ( CurationTask, @@ -106,6 +107,75 @@ def test_gets_existing_grid_by_session_id( mock_grid_cls.return_value.get.assert_called_once_with(synapse_client=client) task.create_grid_session.assert_not_called() + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_recreates_grid_when_attached_session_is_gone( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When the task points at a session that no longer exists (fetching it + returns a 404), a new grid session is created, re-linked, and returned.""" + # GIVEN: A task pointing at a stale session whose fetch returns 404 + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus( + execution_details=GridExecutionDetails(active_session_id="stale-session") + ) + task = _build_mock_task(status) + mock_curation_task_cls.return_value.get.return_value = task + + not_found = SynapseHTTPError("not found", response=Mock(status_code=404)) + mock_grid_cls.return_value.get.side_effect = not_found + + created_grid = Mock(session_id="fresh-session") + task.create_grid_session.return_value = created_grid + + # WHEN: Getting the curator grid for the task + result = get_curator_grid( + task_id=123, + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + + # THEN: The stale session is fetched (and 404s), then a new grid session + # is created with the given owner and timeout and returned + mock_grid_cls.assert_called_once_with(session_id="stale-session") + assert result is created_grid + task.create_grid_session.assert_called_once_with( + owner_principal_id=42, + timeout=30, + synapse_client=client, + ) + + @patch.object(grid_module, "Grid") + @patch.object(grid_module, "CurationTask") + @patch.object(grid_module, "Synapse") + def test_non_404_error_fetching_attached_session_propagates( + self, mock_synapse, mock_curation_task_cls, mock_grid_cls + ): + """When fetching the attached session fails with a non-404 error, the + error propagates and no new grid session is created.""" + # GIVEN: A task pointing at a session whose fetch fails with a 403 + client = _build_mock_client() + mock_synapse.get_client.return_value = client + status = CurationTaskStatus( + execution_details=GridExecutionDetails(active_session_id="abc-123") + ) + task = _build_mock_task(status) + mock_curation_task_cls.return_value.get.return_value = task + + forbidden = SynapseHTTPError("forbidden", response=Mock(status_code=403)) + mock_grid_cls.return_value.get.side_effect = forbidden + + # WHEN: Getting the curator grid for the task + # THEN: The error propagates unchanged + with pytest.raises(SynapseHTTPError, match="forbidden"): + get_curator_grid(task_id=123, synapse_client=client) + + # AND: No new grid session is created + task.create_grid_session.assert_not_called() + @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") From 98e6f41633ce4790ce63273bcce3008a13ebd666 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 09:29:44 -0700 Subject: [PATCH 10/16] update doc to use new function --- .../curator/metadata_contribution.md | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_contribution.md b/docs/guides/extensions/curator/metadata_contribution.md index 40b07d05e..b7986e58e 100644 --- a/docs/guides/extensions/curator/metadata_contribution.md +++ b/docs/guides/extensions/curator/metadata_contribution.md @@ -7,7 +7,7 @@ This guide shows how to programmatically complete a record-based metadata curati By following this guide, you will: - List curation tasks in a Synapse project -- Create a Grid session for a record-based curation task +- Get or create a Grid session for a record-based curation task - Download metadata from the Grid to a local CSV - Edit the metadata locally - Upload the metadata back into the Grid @@ -100,12 +100,16 @@ from synapseclient.models import CurationTask curation_task = CurationTask(task_id=12345).get() ``` -### Step 3: Create a Grid session for the task +### Step 3: Get or create a Grid session for the task -Each option in Step 2 leaves you with a single `curation_task`. Start a new Grid session on it — it picks the `record_set_id` from the task properties automatically and links the session back to the task. If the task already has an active Grid session linked, calling this replaces the link with the new session. +Each option in Step 2 leaves you with a single `curation_task`. Open its Grid with [get_curator_grid][synapseclient.extensions.curator.get_curator_grid]. Given the task's ID, it returns the Grid already linked to the task, or — if none is linked yet — creates a new Grid session, links it to the task, and returns it. The first call starts the session; every subsequent call returns that same session, so you can pick up where you left off without accidentally starting over. + +The `record_set_id` and the authorization mode are taken from the task properties automatically, so you do not need to specify them here. ```python -latest_grid = curation_task.create_grid_session() +from synapseclient.extensions.curator import get_curator_grid + +latest_grid = get_curator_grid(task_id=curation_task.task_id) ``` ### Step 4: Download record-based metadata as a local CSV @@ -230,7 +234,7 @@ Row 2: #### Fix and re-export -If any rows are invalid, recreate a Grid session against the RecordSet (see Step 3), correct the offending rows, and re-run Steps 4–6 to re-export. The validation report is regenerated on each export, so iterate until the report is clean before letting the administrator know your task is ready. +If any rows are invalid, re-open the Grid session (see Step 3 — `get_curator_grid` returns the session already linked to the task), correct the offending rows, and re-run Steps 4–6 to re-export. The validation report is regenerated on each export, so iterate until the report is clean before letting the administrator know your task is ready. > **If get_detailed_validation_results returns None after exporting:** check that record_set.validation_file_handle_id is set after the re-fetch. If it isn't, the export did not complete — re-run export_to_record_set() on an active Grid session against the same RecordSet. @@ -249,7 +253,7 @@ File-based tasks follow the same overall flow as record-based tasks (Steps 1–8 **No CSV import.** `import_csv` is not currently supported for file-based grids. Instead, you can either: - Download the CSV (Step 4) as a local reference, make your edits locally, then copy-paste the values back into the Grid UI -- Make edits directly in the Synapse Grid UI — Step 3 prints the session URL (`https://www.synapse.org/Grid:default?sessionId=...`) after creating the session +- Make edits directly in the Synapse Grid UI — build the session URL from the grid returned in Step 3: `https://www.synapse.org/Grid:default?sessionId={latest_grid.session_id}` **Use `synchronize()` instead of `export_to_record_set()`.** After editing in the Grid UI, push your changes back to the underlying files: @@ -266,10 +270,17 @@ This writes the Grid annotation values back to each file as Synapse annotations. ### Cleaning up a Grid session ```python +# Delete the grid session latest_grid.delete() + +# Remove the deleted session's reference from the task, so the task no longer +# points at a session that no longer exists +status = curation_task.get_status() +status.execution_details = None +curation_task.update_status(curation_task_status=status) ``` -Deleting is permanent — you can no longer re-export from this session. If you spot more issues later, create a new Grid session via Step 3. +Deleting is permanent — you can no longer re-export from this session. If you spot more issues later, get a fresh Grid session via Step 3: `get_curator_grid` creates a new one and links it to the task. ## References @@ -278,7 +289,8 @@ Deleting is permanent — you can no longer re-export from this session. If you - [CurationTask.list][synapseclient.models.CurationTask.list] - List curation tasks in a project - [CurationTask.get][synapseclient.models.CurationTask.get] - Fetch a CurationTask by id -- [CurationTask.create_grid_session][synapseclient.models.CurationTask.create_grid_session] - Create a Grid session for a CurationTask and link it to the task status +- [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] - Get the Grid attached to a CurationTask, creating and linking one if needed +- [CurationTask.create_grid_session][synapseclient.models.CurationTask.create_grid_session] - Always create a new Grid session for a CurationTask and link it to the task status - [CurationTask.set_task_state][synapseclient.models.CurationTask.set_task_state] - Set the state on a CurationTask's status - [Grid.download_csv][synapseclient.models.Grid.download_csv] - Download Grid contents as a local CSV - [Grid.import_csv][synapseclient.models.Grid.import_csv] - Upsert CSV edits back into a Grid session (record-based grids only) From 1e920bfa8453c0bf1be0741459204110a8a496dc Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Tue, 14 Jul 2026 09:33:43 -0700 Subject: [PATCH 11/16] fix docstring --- synapseclient/extensions/curator/get_grid.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/synapseclient/extensions/curator/get_grid.py b/synapseclient/extensions/curator/get_grid.py index 2c95f7491..66317c176 100644 --- a/synapseclient/extensions/curator/get_grid.py +++ b/synapseclient/extensions/curator/get_grid.py @@ -64,9 +64,12 @@ def get_curator_grid( task_id: The unique identifier of the CurationTask to get or create a grid for. owner_principal_id: The principal ID (user or team) that will own a newly - created grid session. Ignored when a session is already attached to - the task. When not provided, the caller becomes the owner (subject to - the task's suggested authorization mode). + created grid session. Used whenever a new session must be created: + when the task has no session attached, or when the attached session + no longer exists and is recreated. Ignored when an existing attached + session is fetched successfully. When not provided, the caller + becomes the owner (subject to the task's suggested authorization + mode). timeout: Seconds to wait for the grid creation job when a new session must be created. Defaults to 120. synapse_client: If not passed in and caching was not disabled by From 2f0507b11b990cff4d2be45acb8a0f159281745c Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 22 Jul 2026 09:51:56 -0700 Subject: [PATCH 12/16] renamed function --- .../curator/metadata_contribution.md | 12 ++++----- synapseclient/extensions/curator/__init__.py | 4 +-- .../{get_grid.py => get_or_create_grid.py} | 8 +++--- ...> unit_test_get_or_create_curator_grid.py} | 26 ++++++++++--------- 4 files changed, 26 insertions(+), 24 deletions(-) rename synapseclient/extensions/curator/{get_grid.py => get_or_create_grid.py} (95%) rename tests/unit/synapseclient/extensions/{unit_test_get_curator_grid.py => unit_test_get_or_create_curator_grid.py} (93%) diff --git a/docs/guides/extensions/curator/metadata_contribution.md b/docs/guides/extensions/curator/metadata_contribution.md index b7986e58e..dbc2838e0 100644 --- a/docs/guides/extensions/curator/metadata_contribution.md +++ b/docs/guides/extensions/curator/metadata_contribution.md @@ -102,14 +102,14 @@ curation_task = CurationTask(task_id=12345).get() ### Step 3: Get or create a Grid session for the task -Each option in Step 2 leaves you with a single `curation_task`. Open its Grid with [get_curator_grid][synapseclient.extensions.curator.get_curator_grid]. Given the task's ID, it returns the Grid already linked to the task, or — if none is linked yet — creates a new Grid session, links it to the task, and returns it. The first call starts the session; every subsequent call returns that same session, so you can pick up where you left off without accidentally starting over. +Each option in Step 2 leaves you with a single `curation_task`. Open its Grid with [get_or_create_curator_grid][synapseclient.extensions.curator.get_or_create_curator_grid]. Given the task's ID, it returns the Grid already linked to the task, or — if none is linked yet — creates a new Grid session, links it to the task, and returns it. The first call starts the session; every subsequent call returns that same session, so you can pick up where you left off without accidentally starting over. The `record_set_id` and the authorization mode are taken from the task properties automatically, so you do not need to specify them here. ```python -from synapseclient.extensions.curator import get_curator_grid +from synapseclient.extensions.curator import get_or_create_curator_grid -latest_grid = get_curator_grid(task_id=curation_task.task_id) +latest_grid = get_or_create_curator_grid(task_id=curation_task.task_id) ``` ### Step 4: Download record-based metadata as a local CSV @@ -234,7 +234,7 @@ Row 2: #### Fix and re-export -If any rows are invalid, re-open the Grid session (see Step 3 — `get_curator_grid` returns the session already linked to the task), correct the offending rows, and re-run Steps 4–6 to re-export. The validation report is regenerated on each export, so iterate until the report is clean before letting the administrator know your task is ready. +If any rows are invalid, re-open the Grid session (see Step 3 — `get_or_create_curator_grid` returns the session already linked to the task), correct the offending rows, and re-run Steps 4–6 to re-export. The validation report is regenerated on each export, so iterate until the report is clean before letting the administrator know your task is ready. > **If get_detailed_validation_results returns None after exporting:** check that record_set.validation_file_handle_id is set after the re-fetch. If it isn't, the export did not complete — re-run export_to_record_set() on an active Grid session against the same RecordSet. @@ -280,7 +280,7 @@ status.execution_details = None curation_task.update_status(curation_task_status=status) ``` -Deleting is permanent — you can no longer re-export from this session. If you spot more issues later, get a fresh Grid session via Step 3: `get_curator_grid` creates a new one and links it to the task. +Deleting is permanent — you can no longer re-export from this session. If you spot more issues later, get a fresh Grid session via Step 3: `get_or_create_curator_grid` creates a new one and links it to the task. ## References @@ -289,7 +289,7 @@ Deleting is permanent — you can no longer re-export from this session. If you - [CurationTask.list][synapseclient.models.CurationTask.list] - List curation tasks in a project - [CurationTask.get][synapseclient.models.CurationTask.get] - Fetch a CurationTask by id -- [get_curator_grid][synapseclient.extensions.curator.get_curator_grid] - Get the Grid attached to a CurationTask, creating and linking one if needed +- [get_or_create_curator_grid][synapseclient.extensions.curator.get_or_create_curator_grid] - Get the Grid attached to a CurationTask, creating and linking one if needed - [CurationTask.create_grid_session][synapseclient.models.CurationTask.create_grid_session] - Always create a new Grid session for a CurationTask and link it to the task status - [CurationTask.set_task_state][synapseclient.models.CurationTask.set_task_state] - Set the state on a CurationTask's status - [Grid.download_csv][synapseclient.models.Grid.download_csv] - Download Grid contents as a local CSV diff --git a/synapseclient/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index 0a73f3a24..c32be16f2 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -5,7 +5,7 @@ """ from .file_based_metadata_task import create_file_based_metadata_task -from .get_grid import get_curator_grid +from .get_or_create_grid import get_or_create_curator_grid from .record_based_metadata_task import create_record_based_metadata_task from .schema_generation import generate_jsonld, generate_jsonschema from .schema_management import ( @@ -17,7 +17,7 @@ from .schema_registry import query_schema_registry __all__ = [ - "get_curator_grid", + "get_or_create_curator_grid", "create_file_based_metadata_task", "create_record_based_metadata_task", "query_schema_registry", diff --git a/synapseclient/extensions/curator/get_grid.py b/synapseclient/extensions/curator/get_or_create_grid.py similarity index 95% rename from synapseclient/extensions/curator/get_grid.py rename to synapseclient/extensions/curator/get_or_create_grid.py index 66317c176..bb6b0f905 100644 --- a/synapseclient/extensions/curator/get_grid.py +++ b/synapseclient/extensions/curator/get_or_create_grid.py @@ -1,7 +1,7 @@ """ High-level helper for getting or creating a Grid for a CurationTask. -This module provides a single library function, get_curator_grid, that a data +This module provides a single library function, get_or_create_curator_grid, that a data contributor can call after being assigned a CurationTask by a data manager. It returns the Grid that is currently attached to the task, creating and attaching a new grid session first if none exists yet. @@ -16,7 +16,7 @@ ) -def get_curator_grid( +def get_or_create_curator_grid( task_id: int, *, owner_principal_id: int | None = None, @@ -51,12 +51,12 @@ def get_curator_grid( subsequent calls return that same grid. ```python import synapseclient - from synapseclient.extensions.curator import get_curator_grid + from synapseclient.extensions.curator import get_or_create_curator_grid syn = synapseclient.Synapse() syn.login() - grid = get_curator_grid(task_id=123) + grid = get_or_create_curator_grid(task_id=123) print(f"Grid session: {grid.session_id}") ``` diff --git a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py similarity index 93% rename from tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py rename to tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py index 9a831af22..ee38cd842 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py @@ -1,5 +1,5 @@ """ -Unit tests for synapseclient.extensions.curator.get_grid. +Unit tests for synapseclient.extensions.curator.get_or_create_grid. """ import sys @@ -9,14 +9,16 @@ from synapseclient import Synapse from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.extensions.curator.get_grid import get_curator_grid +from synapseclient.extensions.curator.get_or_create_grid import ( + get_or_create_curator_grid, +) from synapseclient.models.curation import ( CurationTask, CurationTaskStatus, GridExecutionDetails, ) -grid_module = sys.modules["synapseclient.extensions.curator.get_grid"] +grid_module = sys.modules["synapseclient.extensions.curator.get_or_create_grid"] def _build_mock_client(): @@ -33,8 +35,8 @@ def _build_mock_task(status): return task -class TestGetCuratorGrid: - """Test cases for the get_curator_grid function.""" +class TestGetOrCreateCuratorGrid: + """Test cases for the get_or_create_curator_grid function.""" @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @@ -58,7 +60,7 @@ def test_creates_grid_when_no_session_attached( mock_curation_task_cls.return_value.get.return_value = task # WHEN: Getting the curator grid for the task - result = get_curator_grid( + result = get_or_create_curator_grid( task_id=123, owner_principal_id=42, timeout=30, @@ -98,7 +100,7 @@ def test_gets_existing_grid_by_session_id( mock_grid_cls.return_value.get.return_value = fetched_grid # WHEN: Getting the curator grid for the task - result = get_curator_grid(task_id=123, synapse_client=client) + result = get_or_create_curator_grid(task_id=123, synapse_client=client) # THEN: The existing grid is fetched directly by its session id and # returned, and no new grid session is created @@ -131,7 +133,7 @@ def test_recreates_grid_when_attached_session_is_gone( task.create_grid_session.return_value = created_grid # WHEN: Getting the curator grid for the task - result = get_curator_grid( + result = get_or_create_curator_grid( task_id=123, owner_principal_id=42, timeout=30, @@ -171,7 +173,7 @@ def test_non_404_error_fetching_attached_session_propagates( # WHEN: Getting the curator grid for the task # THEN: The error propagates unchanged with pytest.raises(SynapseHTTPError, match="forbidden"): - get_curator_grid(task_id=123, synapse_client=client) + get_or_create_curator_grid(task_id=123, synapse_client=client) # AND: No new grid session is created task.create_grid_session.assert_not_called() @@ -193,7 +195,7 @@ def test_error_propagates_to_caller( # WHEN: Getting the curator grid for the task # THEN: The error propagates unchanged to the caller with pytest.raises(RuntimeError, match="boom") as exc_info: - get_curator_grid(task_id=123, synapse_client=client) + get_or_create_curator_grid(task_id=123, synapse_client=client) # AND: The propagated exception is the original one assert exc_info.value is boom @@ -217,7 +219,7 @@ def test_creates_grid_when_execution_details_is_none( mock_curation_task_cls.return_value.get.return_value = task # WHEN: Getting the curator grid for the task - result = get_curator_grid( + result = get_or_create_curator_grid( task_id=123, owner_principal_id=42, timeout=30, @@ -258,7 +260,7 @@ class _NonGridExecutionDetails: # WHEN: Getting the curator grid for the task # THEN: A ValueError is raised with pytest.raises(ValueError, match="non-grid execution details"): - get_curator_grid(task_id=123, synapse_client=client) + get_or_create_curator_grid(task_id=123, synapse_client=client) # AND: Neither the grid lookup nor grid creation is attempted mock_grid_cls.return_value.get.assert_not_called() From 80a7155e7deba832a028e29d45a6f6db74f18117 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Wed, 22 Jul 2026 10:02:43 -0700 Subject: [PATCH 13/16] fix bug in doc example --- .../extensions/curator/metadata_contribution.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/guides/extensions/curator/metadata_contribution.md b/docs/guides/extensions/curator/metadata_contribution.md index dbc2838e0..4556b4206 100644 --- a/docs/guides/extensions/curator/metadata_contribution.md +++ b/docs/guides/extensions/curator/metadata_contribution.md @@ -58,9 +58,9 @@ for task in all_tasks: curation_task = all_tasks[0] ``` -#### Option B: Filter the list by assignee, state, or name +#### Option B: Filter the list by assignee, state, or data type -Use this when you want to find tasks assigned to you, tasks in a specific state, or locate a task by name. Each filter still returns a list — pick the one you want from it. +Use this when you want to find tasks assigned to you, tasks in a specific state, or locate a task by its data type. Each filter still returns a list — pick the one you want from it. ```python from synapseclient.models import CurationTask @@ -78,12 +78,12 @@ in_progress_tasks = list( CurationTask.list(project_id=PROJECT_ID, state_filter=["IN_PROGRESS"]) ) -# Find a task by name (list() does not support name filtering directly — filter after listing) -target_name = "AnimalMetadata_Curation" -named_tasks = [ +# Find a task by data type (list() does not support data type filtering directly — filter after listing) +target_data_type = "AnimalMetadata" +matching_tasks = [ task for task in CurationTask.list(project_id=PROJECT_ID) - if task.name == target_name + if task.data_type == target_data_type ] # Select the task you want from whichever list you built above From 9f5a01a237482c7bbf76af7862d17e826edd7aaf Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Thu, 23 Jul 2026 08:37:09 -0700 Subject: [PATCH 14/16] removed logging messaging --- .../extensions/curator/get_or_create_grid.py | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/synapseclient/extensions/curator/get_or_create_grid.py b/synapseclient/extensions/curator/get_or_create_grid.py index bb6b0f905..08f0341ac 100644 --- a/synapseclient/extensions/curator/get_or_create_grid.py +++ b/synapseclient/extensions/curator/get_or_create_grid.py @@ -89,14 +89,10 @@ def get_or_create_curator_grid( client = Synapse.get_client(synapse_client=synapse_client) # Step 1: get the CurationTask. - client.logger.info(f"Attempting to get CurationTask {task_id}.") task = CurationTask(task_id=task_id).get(synapse_client=client) - client.logger.info(f"Got CurationTask {task_id}.") # Step 2: check whether a grid session is already attached to the task. - client.logger.info(f"Attempting to get status for CurationTask {task_id}.") status = task.get_status(synapse_client=client) - client.logger.info(f"Got status for CurationTask {task_id}.") execution_details = status.execution_details active_session_id = None @@ -111,14 +107,8 @@ def get_or_create_curator_grid( # Step 3: a session is attached, so try to get that Grid and return it. if active_session_id: - client.logger.info( - f"CurationTask {task_id} already has grid session {active_session_id}; " - "attempting to get the existing grid." - ) try: - grid = Grid(session_id=active_session_id).get(synapse_client=client) - client.logger.info(f"Got grid session {active_session_id}.") - return grid + return Grid(session_id=active_session_id).get(synapse_client=client) except SynapseHTTPError as exc: # The task points at a session that no longer exists if exc.response is None or exc.response.status_code != 404: @@ -129,14 +119,8 @@ def get_or_create_curator_grid( ) # Step 4: no usable session attached, so create one, attach it, and return it. - client.logger.info( - f"CurationTask {task_id} has no attached grid session; " - "attempting to create one." - ) - grid = task.create_grid_session( + return task.create_grid_session( owner_principal_id=owner_principal_id, timeout=timeout, synapse_client=client, ) - client.logger.info(f"Created grid session for CurationTask {task_id}.") - return grid From 35ee9eb9d0d964e69c309d15743e9a549e273f41 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Thu, 23 Jul 2026 08:42:10 -0700 Subject: [PATCH 15/16] added async verison of function --- synapseclient/extensions/curator/__init__.py | 6 +- .../extensions/curator/get_or_create_grid.py | 92 ++++++++++++++++++- .../unit_test_get_or_create_curator_grid.py | 91 +++++++++--------- 3 files changed, 141 insertions(+), 48 deletions(-) diff --git a/synapseclient/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index c32be16f2..bc42dd5d7 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -5,7 +5,10 @@ """ from .file_based_metadata_task import create_file_based_metadata_task -from .get_or_create_grid import get_or_create_curator_grid +from .get_or_create_grid import ( + get_or_create_curator_grid, + get_or_create_curator_grid_async, +) from .record_based_metadata_task import create_record_based_metadata_task from .schema_generation import generate_jsonld, generate_jsonschema from .schema_management import ( @@ -18,6 +21,7 @@ __all__ = [ "get_or_create_curator_grid", + "get_or_create_curator_grid_async", "create_file_based_metadata_task", "create_record_based_metadata_task", "query_schema_registry", diff --git a/synapseclient/extensions/curator/get_or_create_grid.py b/synapseclient/extensions/curator/get_or_create_grid.py index 08f0341ac..6ea73b35d 100644 --- a/synapseclient/extensions/curator/get_or_create_grid.py +++ b/synapseclient/extensions/curator/get_or_create_grid.py @@ -8,6 +8,7 @@ """ from synapseclient import Synapse # type: ignore +from synapseclient.core.async_utils import wrap_async_to_sync # type: ignore from synapseclient.core.exceptions import SynapseHTTPError # type: ignore from synapseclient.models import ( # type: ignore CurationTask, @@ -79,6 +80,87 @@ def get_or_create_curator_grid( Returns: The Grid attached to the CurationTask. + Raises: + ValueError: If the task's status has non-grid execution details, or the + task's properties are of an unsupported type for grid creation. + SynapseHTTPError: If there are issues communicating with Synapse. A 404 + on the attached session is handled by creating a new one; any other + HTTP error propagates. + """ + return wrap_async_to_sync( + coroutine=get_or_create_curator_grid_async( + task_id=task_id, + owner_principal_id=owner_principal_id, + timeout=timeout, + synapse_client=synapse_client, + ) + ) + + +async def get_or_create_curator_grid_async( + task_id: int, + *, + owner_principal_id: int | None = None, + timeout: int = 120, + synapse_client: Synapse | None = None, +) -> Grid: + """ + Get the Grid attached to a CurationTask, creating one if none exists. + + This is the high-level entry point for a data contributor who has been + assigned a CurationTask by a data manager and wants to start curating in a + grid. It performs the following steps: + + 1. Gets the CurationTask from Synapse. + 2. Checks whether a grid session is already attached to the task. + 3. If a session is attached, gets that Grid from Synapse and returns it. If + the attached session no longer exists (it was deleted out from under the + task), a new grid session is created and attached instead. + 4. If no session is attached, creates a new grid session, attaches it to the + task, and returns the new Grid. + + When a new grid session is created it uses the task's suggested authorization + mode (set on the task by the data manager). SESSION_OWNER limits access to + the session owner and their team, while SOURCE_BENEFACTOR extends access to + anyone with EDIT permission on the data being curated. When the task has no + suggested authorization mode, the caller becomes the session owner. + + Example: Get or create the grid for an assigned curation task +   + A data contributor opens the grid for a task they were assigned. The + first call creates the grid session and attaches it to the task; + subsequent calls return that same grid. + ```python + import asyncio + import synapseclient + from synapseclient.extensions.curator import get_or_create_curator_grid_async + + syn = synapseclient.Synapse() + syn.login() + + grid = asyncio.run(get_or_create_curator_grid_async(task_id=123)) + print(f"Grid session: {grid.session_id}") + ``` + + Arguments: + task_id: The unique identifier of the CurationTask to get or create a + grid for. + owner_principal_id: The principal ID (user or team) that will own a newly + created grid session. Used whenever a new session must be created: + when the task has no session attached, or when the attached session + no longer exists and is recreated. Ignored when an existing attached + session is fetched successfully. When not provided, the caller + becomes the owner (subject to the task's suggested authorization + mode). + timeout: Seconds to wait for the grid creation job when a new session must + be created. 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: + The Grid attached to the CurationTask. + Raises: ValueError: If the task's status has non-grid execution details, or the task's properties are of an unsupported type for grid creation. @@ -89,10 +171,10 @@ def get_or_create_curator_grid( client = Synapse.get_client(synapse_client=synapse_client) # Step 1: get the CurationTask. - task = CurationTask(task_id=task_id).get(synapse_client=client) + task = await CurationTask(task_id=task_id).get_async(synapse_client=client) # Step 2: check whether a grid session is already attached to the task. - status = task.get_status(synapse_client=client) + status = await task.get_status_async(synapse_client=client) execution_details = status.execution_details active_session_id = None @@ -108,7 +190,9 @@ def get_or_create_curator_grid( # Step 3: a session is attached, so try to get that Grid and return it. if active_session_id: try: - return Grid(session_id=active_session_id).get(synapse_client=client) + return await Grid(session_id=active_session_id).get_async( + synapse_client=client + ) except SynapseHTTPError as exc: # The task points at a session that no longer exists if exc.response is None or exc.response.status_code != 404: @@ -119,7 +203,7 @@ def get_or_create_curator_grid( ) # Step 4: no usable session attached, so create one, attach it, and return it. - return task.create_grid_session( + return await task.create_grid_session_async( owner_principal_id=owner_principal_id, timeout=timeout, synapse_client=client, diff --git a/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py index ee38cd842..cf3ee59de 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py @@ -3,14 +3,14 @@ """ import sys -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from synapseclient import Synapse from synapseclient.core.exceptions import SynapseHTTPError from synapseclient.extensions.curator.get_or_create_grid import ( - get_or_create_curator_grid, + get_or_create_curator_grid_async, ) from synapseclient.models.curation import ( CurationTask, @@ -29,19 +29,20 @@ def _build_mock_client(): def _build_mock_task(status): - """Build a mock CurationTask whose accessors are wired up.""" + """Build a mock CurationTask whose async accessors are wired up.""" task = Mock(spec=CurationTask) - task.get_status.return_value = status + task.get_status_async = AsyncMock(return_value=status) + task.create_grid_session_async = AsyncMock() return task -class TestGetOrCreateCuratorGrid: - """Test cases for the get_or_create_curator_grid function.""" +class TestGetOrCreateCuratorGridAsync: + """Test cases for the get_or_create_curator_grid_async function.""" @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_creates_grid_when_no_session_attached( + async def test_creates_grid_when_no_session_attached( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When the task has grid execution details but no active session id, @@ -56,11 +57,11 @@ def test_creates_grid_when_no_session_attached( ) task = _build_mock_task(status) created_grid = Mock(session_id="new-session") - task.create_grid_session.return_value = created_grid - mock_curation_task_cls.return_value.get.return_value = task + task.create_grid_session_async.return_value = created_grid + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) # WHEN: Getting the curator grid for the task - result = get_or_create_curator_grid( + result = await get_or_create_curator_grid_async( task_id=123, owner_principal_id=42, timeout=30, @@ -70,18 +71,18 @@ def test_creates_grid_when_no_session_attached( # THEN: A new grid session is created with the given owner and timeout # and returned assert result is created_grid - task.create_grid_session.assert_called_once_with( + task.create_grid_session_async.assert_called_once_with( owner_principal_id=42, timeout=30, synapse_client=client, ) # AND: The existing-grid lookup is not attempted - mock_grid_cls.return_value.get.assert_not_called() + mock_grid_cls.return_value.get_async.assert_not_called() @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_gets_existing_grid_by_session_id( + async def test_gets_existing_grid_by_session_id( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When the task already has an active grid session, that grid is @@ -94,25 +95,29 @@ def test_gets_existing_grid_by_session_id( execution_details=GridExecutionDetails(active_session_id="abc-123") ) task = _build_mock_task(status) - mock_curation_task_cls.return_value.get.return_value = task + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) fetched_grid = Mock(session_id="abc-123") - mock_grid_cls.return_value.get.return_value = fetched_grid + mock_grid_cls.return_value.get_async = AsyncMock(return_value=fetched_grid) # WHEN: Getting the curator grid for the task - result = get_or_create_curator_grid(task_id=123, synapse_client=client) + result = await get_or_create_curator_grid_async( + task_id=123, synapse_client=client + ) # THEN: The existing grid is fetched directly by its session id and # returned, and no new grid session is created assert result is fetched_grid mock_grid_cls.assert_called_once_with(session_id="abc-123") - mock_grid_cls.return_value.get.assert_called_once_with(synapse_client=client) - task.create_grid_session.assert_not_called() + mock_grid_cls.return_value.get_async.assert_called_once_with( + synapse_client=client + ) + task.create_grid_session_async.assert_not_called() @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_recreates_grid_when_attached_session_is_gone( + async def test_recreates_grid_when_attached_session_is_gone( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When the task points at a session that no longer exists (fetching it @@ -124,16 +129,16 @@ def test_recreates_grid_when_attached_session_is_gone( execution_details=GridExecutionDetails(active_session_id="stale-session") ) task = _build_mock_task(status) - mock_curation_task_cls.return_value.get.return_value = task + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) not_found = SynapseHTTPError("not found", response=Mock(status_code=404)) - mock_grid_cls.return_value.get.side_effect = not_found + mock_grid_cls.return_value.get_async = AsyncMock(side_effect=not_found) created_grid = Mock(session_id="fresh-session") - task.create_grid_session.return_value = created_grid + task.create_grid_session_async.return_value = created_grid # WHEN: Getting the curator grid for the task - result = get_or_create_curator_grid( + result = await get_or_create_curator_grid_async( task_id=123, owner_principal_id=42, timeout=30, @@ -144,7 +149,7 @@ def test_recreates_grid_when_attached_session_is_gone( # is created with the given owner and timeout and returned mock_grid_cls.assert_called_once_with(session_id="stale-session") assert result is created_grid - task.create_grid_session.assert_called_once_with( + task.create_grid_session_async.assert_called_once_with( owner_principal_id=42, timeout=30, synapse_client=client, @@ -153,7 +158,7 @@ def test_recreates_grid_when_attached_session_is_gone( @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_non_404_error_fetching_attached_session_propagates( + async def test_non_404_error_fetching_attached_session_propagates( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When fetching the attached session fails with a non-404 error, the @@ -165,23 +170,23 @@ def test_non_404_error_fetching_attached_session_propagates( execution_details=GridExecutionDetails(active_session_id="abc-123") ) task = _build_mock_task(status) - mock_curation_task_cls.return_value.get.return_value = task + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) forbidden = SynapseHTTPError("forbidden", response=Mock(status_code=403)) - mock_grid_cls.return_value.get.side_effect = forbidden + mock_grid_cls.return_value.get_async = AsyncMock(side_effect=forbidden) # WHEN: Getting the curator grid for the task # THEN: The error propagates unchanged with pytest.raises(SynapseHTTPError, match="forbidden"): - get_or_create_curator_grid(task_id=123, synapse_client=client) + await get_or_create_curator_grid_async(task_id=123, synapse_client=client) # AND: No new grid session is created - task.create_grid_session.assert_not_called() + task.create_grid_session_async.assert_not_called() @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_error_propagates_to_caller( + async def test_error_propagates_to_caller( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When a Synapse call fails, the exception propagates to the caller @@ -190,12 +195,12 @@ def test_error_propagates_to_caller( client = _build_mock_client() mock_synapse.get_client.return_value = client boom = RuntimeError("boom") - mock_curation_task_cls.return_value.get.side_effect = boom + mock_curation_task_cls.return_value.get_async = AsyncMock(side_effect=boom) # WHEN: Getting the curator grid for the task # THEN: The error propagates unchanged to the caller with pytest.raises(RuntimeError, match="boom") as exc_info: - get_or_create_curator_grid(task_id=123, synapse_client=client) + await get_or_create_curator_grid_async(task_id=123, synapse_client=client) # AND: The propagated exception is the original one assert exc_info.value is boom @@ -203,7 +208,7 @@ def test_error_propagates_to_caller( @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_creates_grid_when_execution_details_is_none( + async def test_creates_grid_when_execution_details_is_none( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When the task status has no execution details at all (a task that has @@ -215,11 +220,11 @@ def test_creates_grid_when_execution_details_is_none( status = CurationTaskStatus(execution_details=None) task = _build_mock_task(status) created_grid = Mock(session_id="new-session") - task.create_grid_session.return_value = created_grid - mock_curation_task_cls.return_value.get.return_value = task + task.create_grid_session_async.return_value = created_grid + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) # WHEN: Getting the curator grid for the task - result = get_or_create_curator_grid( + result = await get_or_create_curator_grid_async( task_id=123, owner_principal_id=42, timeout=30, @@ -229,18 +234,18 @@ def test_creates_grid_when_execution_details_is_none( # THEN: A new grid session is created with the given owner and timeout # and returned assert result is created_grid - task.create_grid_session.assert_called_once_with( + task.create_grid_session_async.assert_called_once_with( owner_principal_id=42, timeout=30, synapse_client=client, ) # AND: The existing-grid lookup is not attempted - mock_grid_cls.return_value.get.assert_not_called() + mock_grid_cls.return_value.get_async.assert_not_called() @patch.object(grid_module, "Grid") @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") - def test_raises_when_status_has_non_grid_execution_details( + async def test_raises_when_status_has_non_grid_execution_details( self, mock_synapse, mock_curation_task_cls, mock_grid_cls ): """When the task status carries a non-grid execution details type, a @@ -255,13 +260,13 @@ class _NonGridExecutionDetails: mock_synapse.get_client.return_value = client status = CurationTaskStatus(execution_details=_NonGridExecutionDetails()) task = _build_mock_task(status) - mock_curation_task_cls.return_value.get.return_value = task + mock_curation_task_cls.return_value.get_async = AsyncMock(return_value=task) # WHEN: Getting the curator grid for the task # THEN: A ValueError is raised with pytest.raises(ValueError, match="non-grid execution details"): - get_or_create_curator_grid(task_id=123, synapse_client=client) + await get_or_create_curator_grid_async(task_id=123, synapse_client=client) # AND: Neither the grid lookup nor grid creation is attempted - mock_grid_cls.return_value.get.assert_not_called() - task.create_grid_session.assert_not_called() + mock_grid_cls.return_value.get_async.assert_not_called() + task.create_grid_session_async.assert_not_called() From a13417720563655a402c9105df84bfd9d5f843a6 Mon Sep 17 00:00:00 2001 From: Andrew Lamb Date: Thu, 23 Jul 2026 08:47:58 -0700 Subject: [PATCH 16/16] moved mock client to conftest --- tests/unit/conftest.py | 9 +++++ .../unit_test_get_or_create_curator_grid.py | 36 ++++++++----------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b5e7167a2..a755b351b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,6 +7,7 @@ import platform import time import urllib.request +from unittest.mock import Mock import pytest from pytest_socket import SocketBlockedError, disable_socket @@ -53,3 +54,11 @@ def syn(): syn.logger = logging.getLogger(SILENT_LOGGER_NAME) Synapse.set_client(syn) return syn + + +@pytest.fixture +def mock_synapse_client(): + """A Synapse-spec mock with a usable logger.""" + client = Mock(spec=Synapse) + client.logger = Mock() + return client diff --git a/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py index cf3ee59de..8c1595ef1 100644 --- a/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py +++ b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py @@ -7,7 +7,6 @@ import pytest -from synapseclient import Synapse from synapseclient.core.exceptions import SynapseHTTPError from synapseclient.extensions.curator.get_or_create_grid import ( get_or_create_curator_grid_async, @@ -21,13 +20,6 @@ grid_module = sys.modules["synapseclient.extensions.curator.get_or_create_grid"] -def _build_mock_client(): - """A Synapse-spec mock with a usable logger.""" - client = Mock(spec=Synapse) - client.logger = Mock() - return client - - def _build_mock_task(status): """Build a mock CurationTask whose async accessors are wired up.""" task = Mock(spec=CurationTask) @@ -43,14 +35,14 @@ class TestGetOrCreateCuratorGridAsync: @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_creates_grid_when_no_session_attached( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When the task has grid execution details but no active session id, a new grid session is created (with the given owner and timeout) and returned, and the existing-grid lookup is not attempted.""" # GIVEN: A task whose status has grid execution details but no active # session id set - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus( execution_details=GridExecutionDetails(active_session_id=None) @@ -83,13 +75,13 @@ async def test_creates_grid_when_no_session_attached( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_gets_existing_grid_by_session_id( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When the task already has an active grid session, that grid is fetched directly by its session id and returned, and no new grid session is created.""" # GIVEN: A task whose status has an active grid session id - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus( execution_details=GridExecutionDetails(active_session_id="abc-123") @@ -118,12 +110,12 @@ async def test_gets_existing_grid_by_session_id( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_recreates_grid_when_attached_session_is_gone( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When the task points at a session that no longer exists (fetching it returns a 404), a new grid session is created, re-linked, and returned.""" # GIVEN: A task pointing at a stale session whose fetch returns 404 - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus( execution_details=GridExecutionDetails(active_session_id="stale-session") @@ -159,12 +151,12 @@ async def test_recreates_grid_when_attached_session_is_gone( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_non_404_error_fetching_attached_session_propagates( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When fetching the attached session fails with a non-404 error, the error propagates and no new grid session is created.""" # GIVEN: A task pointing at a session whose fetch fails with a 403 - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus( execution_details=GridExecutionDetails(active_session_id="abc-123") @@ -187,12 +179,12 @@ async def test_non_404_error_fetching_attached_session_propagates( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_error_propagates_to_caller( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When a Synapse call fails, the exception propagates to the caller with its original traceback.""" # GIVEN: A task whose get call fails with an error - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client boom = RuntimeError("boom") mock_curation_task_cls.return_value.get_async = AsyncMock(side_effect=boom) @@ -209,13 +201,13 @@ async def test_error_propagates_to_caller( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_creates_grid_when_execution_details_is_none( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When the task status has no execution details at all (a task that has never had a grid), a new grid session is created and returned, and the existing-grid lookup is not attempted.""" # GIVEN: A task whose status has no execution details - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus(execution_details=None) task = _build_mock_task(status) @@ -246,7 +238,7 @@ async def test_creates_grid_when_execution_details_is_none( @patch.object(grid_module, "CurationTask") @patch.object(grid_module, "Synapse") async def test_raises_when_status_has_non_grid_execution_details( - self, mock_synapse, mock_curation_task_cls, mock_grid_cls + self, mock_synapse, mock_curation_task_cls, mock_grid_cls, mock_synapse_client ): """When the task status carries a non-grid execution details type, a ValueError is raised and neither the grid lookup nor grid creation is @@ -256,7 +248,7 @@ async def test_raises_when_status_has_non_grid_execution_details( class _NonGridExecutionDetails: pass - client = _build_mock_client() + client = mock_synapse_client mock_synapse.get_client.return_value = client status = CurationTaskStatus(execution_details=_NonGridExecutionDetails()) task = _build_mock_task(status)