diff --git a/docs/guides/extensions/curator/metadata_contribution.md b/docs/guides/extensions/curator/metadata_contribution.md index 40b07d05e..4556b4206 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 @@ -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 @@ -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_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 -latest_grid = curation_task.create_grid_session() +from synapseclient.extensions.curator import get_or_create_curator_grid + +latest_grid = get_or_create_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_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. @@ -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_or_create_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_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 - [Grid.import_csv][synapseclient.models.Grid.import_csv] - Upsert CSV edits back into a Grid session (record-based grids only) diff --git a/docs/reference/experimental/async/curator.md b/docs/reference/experimental/async/curator.md index 39e62f705..85872d53a 100644 --- a/docs/reference/experimental/async/curator.md +++ b/docs/reference/experimental/async/curator.md @@ -62,6 +62,7 @@ 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 b330fc3ec..4976bc93b 100644 --- a/docs/reference/experimental/sync/curator.md +++ b/docs/reference/experimental/sync/curator.md @@ -62,6 +62,7 @@ inherited_members: true members: - create + - get - export_to_record_set - synchronize - download_csv 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", 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/extensions/curator/__init__.py b/synapseclient/extensions/curator/__init__.py index ba6bdfee9..bc42dd5d7 100644 --- a/synapseclient/extensions/curator/__init__.py +++ b/synapseclient/extensions/curator/__init__.py @@ -5,6 +5,10 @@ """ from .file_based_metadata_task import create_file_based_metadata_task +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 ( @@ -16,6 +20,8 @@ from .schema_registry import query_schema_registry __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 new file mode 100644 index 000000000..6ea73b35d --- /dev/null +++ b/synapseclient/extensions/curator/get_or_create_grid.py @@ -0,0 +1,210 @@ +""" +High-level helper for getting or creating a Grid for a CurationTask. + +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. +""" + +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, + Grid, + GridExecutionDetails, +) + + +def get_or_create_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. 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 synapseclient + from synapseclient.extensions.curator import get_or_create_curator_grid + + syn = synapseclient.Synapse() + syn.login() + + grid = get_or_create_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. 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. + 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. + 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) + + # Step 1: get the CurationTask. + 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 = await task.get_status_async(synapse_client=client) + + 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} has non-grid execution details " + f"(got {type(execution_details).__name__}); " + "cannot get or create a grid for it." + ) + + # Step 3: a session is attached, so try to get that Grid and return it. + if active_session_id: + try: + 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: + 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. + return await task.create_grid_session_async( + owner_principal_id=owner_principal_id, + timeout=timeout, + synapse_client=client, + ) 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: 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 new file mode 100644 index 000000000..8c1595ef1 --- /dev/null +++ b/tests/unit/synapseclient/extensions/unit_test_get_or_create_curator_grid.py @@ -0,0 +1,264 @@ +""" +Unit tests for synapseclient.extensions.curator.get_or_create_grid. +""" + +import sys +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from synapseclient.core.exceptions import SynapseHTTPError +from synapseclient.extensions.curator.get_or_create_grid import ( + get_or_create_curator_grid_async, +) +from synapseclient.models.curation import ( + CurationTask, + CurationTaskStatus, + GridExecutionDetails, +) + +grid_module = sys.modules["synapseclient.extensions.curator.get_or_create_grid"] + + +def _build_mock_task(status): + """Build a mock CurationTask whose async accessors are wired up.""" + task = Mock(spec=CurationTask) + task.get_status_async = AsyncMock(return_value=status) + task.create_grid_session_async = AsyncMock() + return task + + +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") + async def test_creates_grid_when_no_session_attached( + 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 = mock_synapse_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_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 = await get_or_create_curator_grid_async( + 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_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_async.assert_not_called() + + @patch.object(grid_module, "Grid") + @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, 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 = mock_synapse_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_async = AsyncMock(return_value=task) + + fetched_grid = Mock(session_id="abc-123") + mock_grid_cls.return_value.get_async = AsyncMock(return_value=fetched_grid) + + # WHEN: Getting the curator grid for the task + 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_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") + async def test_recreates_grid_when_attached_session_is_gone( + 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 = mock_synapse_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_async = AsyncMock(return_value=task) + + not_found = SynapseHTTPError("not found", response=Mock(status_code=404)) + mock_grid_cls.return_value.get_async = AsyncMock(side_effect=not_found) + + created_grid = Mock(session_id="fresh-session") + task.create_grid_session_async.return_value = created_grid + + # WHEN: Getting the curator grid for the task + result = await get_or_create_curator_grid_async( + 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_async.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") + async def test_non_404_error_fetching_attached_session_propagates( + 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 = mock_synapse_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_async = AsyncMock(return_value=task) + + forbidden = SynapseHTTPError("forbidden", response=Mock(status_code=403)) + 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"): + await get_or_create_curator_grid_async(task_id=123, synapse_client=client) + + # AND: No new grid session is created + task.create_grid_session_async.assert_not_called() + + @patch.object(grid_module, "Grid") + @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, 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 = 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) + + # 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: + 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 + + @patch.object(grid_module, "Grid") + @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, 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 = mock_synapse_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_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 = await get_or_create_curator_grid_async( + 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_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_async.assert_not_called() + + @patch.object(grid_module, "Grid") + @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, 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 + attempted.""" + + # GIVEN: A task whose status has a non-grid execution details type + class _NonGridExecutionDetails: + pass + + client = mock_synapse_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_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"): + 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_async.assert_not_called() + task.create_grid_session_async.assert_not_called()