From 98bfc0076e9be4e6ccf95b76df55cb82156fbc29 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 4 Dec 2025 12:42:05 -0500 Subject: [PATCH 01/28] implement oop to create a form group --- synapseclient/api/__init__.py | 3 + synapseclient/api/form_services.py | 27 +++++++++ synapseclient/models/__init__.py | 3 + synapseclient/models/form.py | 58 +++++++++++++++++++ synapseclient/models/mixins/__init__.py | 2 + synapseclient/models/mixins/form.py | 29 ++++++++++ .../models/protocols/form_protocol.py | 0 7 files changed, 122 insertions(+) create mode 100644 synapseclient/api/form_services.py create mode 100644 synapseclient/models/form.py create mode 100644 synapseclient/models/mixins/form.py create mode 100644 synapseclient/models/protocols/form_protocol.py diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 09c578ed2..d46d099c5 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -87,6 +87,7 @@ put_file_multipart_add, put_file_multipart_complete, ) +from .form_services import create_form_group_async from .json_schema_services import ( bind_json_schema_to_entity, create_organization, @@ -282,4 +283,6 @@ "get_evaluation_acl", "update_evaluation_acl", "get_evaluation_permissions", + # form services + "create_form_group_async", ] diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py new file mode 100644 index 000000000..b1adc545b --- /dev/null +++ b/synapseclient/api/form_services.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from synapseclient import Synapse + + +async def create_form_group_async( + synapse_client: "Synapse", + name: str, +) -> dict[str, Any]: + """ + + Create a form group asynchronously. + + Args: + synapse_client: The Synapse client to use for the request. + name: A globally unique name for the group. Required. Between 3 and 256 characters. + + Returns: + A Form group object as a dictionary. + Object matching + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + return await client.rest_post_async(uri=f"/form/group?name={name}", body={}) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 9a5322727..35ac154d3 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -18,6 +18,7 @@ from synapseclient.models.evaluation import Evaluation from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder +from synapseclient.models.form import FormGroup from synapseclient.models.link import Link from synapseclient.models.materializedview import MaterializedView from synapseclient.models.mixins.table_components import QueryMixin @@ -132,6 +133,8 @@ # JSON Schema models "SchemaOrganization", "JSONSchema", + # Form models + "FormGroup", ] # Static methods to expose as functions diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py new file mode 100644 index 000000000..c742ed0cb --- /dev/null +++ b/synapseclient/models/form.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, Optional + +from synapseclient.core.async_utils import async_to_sync + +if TYPE_CHECKING: + from synapseclient import Synapse + +from dataclasses import dataclass + +from synapseclient.models.mixins.form import FormGroup as FormGroupMixin + + +@dataclass +@async_to_sync +class FormGroup(FormGroupMixin): + async def create_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormGroup": + """ + Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. + + Arguments: + name: A globally unique name for the group. Required. Between 3 and 256 characters. + synapse_client: Optional Synapse client instance for authentication. + + Returns: + A FormGroup object containing the details of the created group. + + Examples: create a form group + + ```python + from synapseclient import Synapse + from synapseclient.models import FormGroup + import asyncio + + async def create_my_form_group(): + syn = Synapse() + syn.login() + + form_group = FormGroup(name="my_unique_form_group_name") + form_group = await form_group.create_async() + print(form_group) + + asyncio.run(create_my_form_group()) + ``` + """ + if not self.name: + raise ValueError("FormGroup 'name' must be provided to create a FormGroup.") + + from synapseclient.api.form_services import create_form_group_async + + response = await create_form_group_async( + synapse_client=synapse_client, + name=self.name, + ) + return self.fill_from_dict(response) diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 02da00ef2..04f0b51b8 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -2,6 +2,7 @@ from synapseclient.models.mixins.access_control import AccessControllable from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator +from synapseclient.models.mixins.form import FormGroup from synapseclient.models.mixins.json_schema import ( BaseJSONSchema, CausingException, @@ -28,4 +29,5 @@ "JSONSchemaValidationStatistics", "ValidationException", "CausingException", + "FormGroup", ] diff --git a/synapseclient/models/mixins/form.py b/synapseclient/models/mixins/form.py new file mode 100644 index 000000000..0cb077f26 --- /dev/null +++ b/synapseclient/models/mixins/form.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import Any, Optional + +from synapseclient.core.async_utils import async_to_sync + + +@dataclass +@async_to_sync +class FormGroup: + group_id: Optional[str] = None + """Unique identifier provided by the system.""" + + name: Optional[str] = None + """Unique name for the group provided by the caller.""" + + created_by: Optional[str] = None + """Id of the user that created this group""" + + created_on: Optional[str] = None + """The date this object was originally created.""" + + def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormGroup": + """Converts a response from the REST API into this dataclass.""" + self.group_id = synapse_response.get("groupId", None) + self.name = synapse_response.get("name", None) + self.created_by = synapse_response.get("createdBy", None) + self.created_on = synapse_response.get("createdOn", None) + + return self diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py new file mode 100644 index 000000000..e69de29bb From 9190e95d152bbfa6c931985478efe7d9f9023dfe Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 4 Dec 2025 14:09:20 -0500 Subject: [PATCH 02/28] added data class; be able to create form data using form group id --- synapseclient/api/form_services.py | 31 +++++ synapseclient/models/__init__.py | 3 +- synapseclient/models/form.py | 73 ++++++++++++ synapseclient/models/mixins/__init__.py | 10 +- synapseclient/models/mixins/form.py | 149 +++++++++++++++++++++++- 5 files changed, 261 insertions(+), 5 deletions(-) diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index b1adc545b..7ca382909 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -1,3 +1,4 @@ +import json from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -25,3 +26,33 @@ async def create_form_group_async( client = Synapse.get_client(synapse_client=synapse_client) return await client.rest_post_async(uri=f"/form/group?name={name}", body={}) + + +async def create_form_data_async( + synapse_client: "Synapse", + group_id: str, + form_change_request: dict[str, Any], +) -> dict[str, Any]: + """ + + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + form_change_request: a dictionary of form change request matching . + + Returns: + A Form data object as a dictionary. + Object matching + + Note: The caller must have the SUBMIT permission on the FormGroup to create/update/submit FormData. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + return await client.rest_post_async( + uri=f"/form/data?groupId={group_id}", + body=json.dumps(form_change_request), + ) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 35ac154d3..084229b4c 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -18,7 +18,7 @@ from synapseclient.models.evaluation import Evaluation from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder -from synapseclient.models.form import FormGroup +from synapseclient.models.form import FormData, FormGroup from synapseclient.models.link import Link from synapseclient.models.materializedview import MaterializedView from synapseclient.models.mixins.table_components import QueryMixin @@ -135,6 +135,7 @@ "JSONSchema", # Form models "FormGroup", + "FormData", ] # Static methods to expose as functions diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index c742ed0cb..a25fd295d 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -7,6 +7,8 @@ from dataclasses import dataclass +from synapseclient.models.mixins.form import FormChangeRequest +from synapseclient.models.mixins.form import FormData as FormDataMixin from synapseclient.models.mixins.form import FormGroup as FormGroupMixin @@ -56,3 +58,74 @@ async def create_my_form_group(): name=self.name, ) return self.fill_from_dict(response) + + +@dataclass +@async_to_sync +class FormData(FormDataMixin): + async def create_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormData": + """ + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + + Returns: + A FormData object containing the details of the created form data. + + Examples: create a form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData, File + import asyncio + + async def create_my_form_data(): + syn = Synapse() + syn.login() + + file = File(id="syn123", download_file=True).get() + file_handle_id = file.file_handle.id + + form_data = FormData( + group_id="123", + name="my_form_data_name", + data_file_handle_id=file_handle_id + ) + form_data = await form_data.create_async() + + print(f"Created FormData: {form_data.form_data_id}") + print(f"Name: {form_data.name}") + print(f"Group ID: {form_data.group_id}") + print(f"Created By: {form_data.created_by}") + print(f"Created On: {form_data.created_on}") + print(f"Data File Handle ID: {form_data.data_file_handle_id}") + + if form_data.submission_status: + print(f"Submission State: {form_data.submission_status.state.value}") + + asyncio.run(create_my_form_data()) + ``` + + """ + from synapseclient.api.form_services import create_form_data_async + + if not self.group_id or not self.name or not self.data_file_handle_id: + raise ValueError( + "'group_id', 'name', and 'data_file_handle_id' must be provided to create a FormData." + ) + + form_change_request = FormChangeRequest( + name=self.name, file_handle_id=self.data_file_handle_id + ).to_dict() + + response = await create_form_data_async( + synapse_client=synapse_client, + group_id=self.group_id, + form_change_request=form_change_request, + ) + return self.fill_from_dict(response) diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 04f0b51b8..12d6f7dbe 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -2,7 +2,12 @@ from synapseclient.models.mixins.access_control import AccessControllable from synapseclient.models.mixins.asynchronous_job import AsynchronousCommunicator -from synapseclient.models.mixins.form import FormGroup +from synapseclient.models.mixins.form import ( + FormChangeRequest, + FormData, + FormGroup, + SubmissionStatus, +) from synapseclient.models.mixins.json_schema import ( BaseJSONSchema, CausingException, @@ -30,4 +35,7 @@ "ValidationException", "CausingException", "FormGroup", + "FormData", + "FormChangeRequest", + "SubmissionStatus", ] diff --git a/synapseclient/models/mixins/form.py b/synapseclient/models/mixins/form.py index 0cb077f26..b6fc5c656 100644 --- a/synapseclient/models/mixins/form.py +++ b/synapseclient/models/mixins/form.py @@ -1,11 +1,9 @@ from dataclasses import dataclass +from enum import Enum from typing import Any, Optional -from synapseclient.core.async_utils import async_to_sync - @dataclass -@async_to_sync class FormGroup: group_id: Optional[str] = None """Unique identifier provided by the system.""" @@ -27,3 +25,148 @@ def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormGroup": self.created_on = synapse_response.get("createdOn", None) return self + + +@dataclass +class FormChangeRequest: + name: Optional[str] = None + """The name of the form. Required for FormData create. Optional for FormData update. Between 3 and 256 characters""" + + file_handle_id: Optional[str] = None + """The fileHandleId for the data of the form.""" + + def to_dict(self) -> dict[str, Any]: + """Converts this dataclass into a dictionary for REST API requests.""" + request_dict: dict[str, Any] = {} + if self.name is not None: + request_dict["name"] = self.name + if self.file_handle_id is not None: + request_dict["fileHandleId"] = self.file_handle_id + return request_dict + + +class StateEnum(str, Enum): + """ + The enumeration of possible FormData submission states. + """ + + WAITING_FOR_SUBMISSION = "WAITING_FOR_SUBMISSION" + """Indicates that the FormData is waiting for the creator to submit it.""" + + SUBMITTED_WAITING_FOR_REVIEW = "SUBMITTED_WAITING_FOR_REVIEW" + """Indicates the FormData has been submitted and is now awaiting review.""" + + ACCEPTED = "ACCEPTED" + """The submitted FormData has been reviewed and accepted.""" + + REJECTED = "REJECTED" + """The submitted FormData has been reviewed but was not accepted. See the rejection message for more details.""" + + +@dataclass +class SubmissionStatus: + """ + The status of a submitted FormData object. + """ + + submitted_on: Optional[str] = None + """The date when the object was submitted.""" + + reviewed_on: Optional[str] = None + """The date when this submission was reviewed.""" + + reviewed_by: Optional[str] = None + """The id of the service user that reviewed the submission.""" + + state: Optional[StateEnum] = None + """The enumeration of possible FormData submission states.""" + + rejection_message: Optional[str] = None + """The message provided by the reviewer when a submission is rejected.""" + + def fill_from_dict(self, synapse_response: dict[str, Any]) -> "SubmissionStatus": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response dictionary from the Synapse REST API. + + Returns: + This SubmissionStatus object with populated fields. + """ + self.submitted_on = synapse_response.get("submittedOn", None) + self.reviewed_on = synapse_response.get("reviewedOn", None) + self.reviewed_by = synapse_response.get("reviewedBy", None) + + # Handle enum conversion + self.state = ( + StateEnum(synapse_response.get("state", None)) + if synapse_response.get("state", None) + else None + ) + self.rejection_message = synapse_response.get("rejectionMessage", None) + + return self + + +@dataclass +class FormData: + """ + Represents a FormData object in Synapse. + """ + + form_data_id: Optional[str] = None + """The system issued identifier that uniquely identifies this object.""" + + etag: Optional[str] = None + """Will change whenever there is a change to this data or its status.""" + + group_id: Optional[str] = None + """The identifier of the group that manages this data. Required.""" + + name: Optional[str] = None + """User provided name for this submission. Required.""" + + created_by: Optional[str] = None + """Id of the user that created this object.""" + + created_on: Optional[str] = None + """The date this object was originally created.""" + + modified_on: Optional[str] = None + """The date this object was last modified.""" + + data_file_handle_id: Optional[str] = None + """The identifier of the data FileHandle for this object.""" + + submission_status: Optional[SubmissionStatus] = None + """The status of a submitted FormData object.""" + + def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormData": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_response: The response dictionary from the Synapse REST API. + + Returns: + This FormData object with populated fields. + """ + self.form_data_id = synapse_response.get("formDataId", None) + self.etag = synapse_response.get("etag", None) + self.group_id = synapse_response.get("groupId", None) + self.name = synapse_response.get("name", None) + self.created_by = synapse_response.get("createdBy", None) + self.created_on = synapse_response.get("createdOn", None) + self.modified_on = synapse_response.get("modifiedOn", None) + self.data_file_handle_id = synapse_response.get("dataFileHandleId", None) + + if ( + "submissionStatus" in synapse_response + and synapse_response["submissionStatus"] is not None + ): + self.submission_status = SubmissionStatus().fill_from_dict( + synapse_response["submissionStatus"] + ) + + return self From 73bbb5d925e729bf4d3fdaa6bce8cacda0ee2904 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 4 Dec 2025 17:16:20 -0500 Subject: [PATCH 03/28] allow list form reviewer --- synapseclient/api/form_services.py | 84 ++++++++++++++++++- synapseclient/models/form.py | 130 ++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index 7ca382909..b34fbf91b 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -1,8 +1,11 @@ import json -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, AsyncGenerator, Generator, Optional + +from synapseclient.api.api_client import rest_post_paginated_async if TYPE_CHECKING: from synapseclient import Synapse + from synapseclient.models.mixins.form import StateEnum async def create_form_group_async( @@ -56,3 +59,82 @@ async def create_form_data_async( uri=f"/form/data?groupId={group_id}", body=json.dumps(form_change_request), ) + + +async def list_form_reviewer_async( + synapse_client: "Synapse", + group_id: str, + filter_by_state: Optional[list["StateEnum"]] = None, +) -> AsyncGenerator[dict[str, Any], None]: + """ + + List FormData objects in a FormGroup that are awaiting review. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + filter_by_state: List of StateEnum values to filter the FormData objects. + Must include at least one element. Valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A single page of result matching the request + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + if not filter_by_state: + raise ValueError("filter_by_state must include at least one StateEnum value.") + + async for item in rest_post_paginated_async( + uri="/form/data/list/reviewer", + body={ + "groupId": group_id, + "filterByState": filter_by_state, + }, + synapse_client=client, + ): + yield item + + +def list_form_reviewer_sync( + synapse_client: "Synapse", + group_id: str, + filter_by_state: Optional[list["StateEnum"]] = None, +) -> Generator[dict[str, Any], None, None]: + """ + + List FormData objects in a FormGroup that are awaiting review. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + filter_by_state: List of StateEnum values to filter the FormData objects. + Must include at least one element. Valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A single page of result matching the request + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + if not filter_by_state: + raise ValueError("filter_by_state must include at least one StateEnum value.") + + for item in client._POST_paginated( + uri="/form/data/list/reviewer", + body={ + "groupId": group_id, + "filterByState": filter_by_state, + }, + ): + yield item diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index a25fd295d..0e2171dff 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, AsyncGenerator, Generator, Optional -from synapseclient.core.async_utils import async_to_sync +from synapseclient.core.async_utils import async_to_sync, skip_async_to_sync if TYPE_CHECKING: from synapseclient import Synapse @@ -10,6 +10,7 @@ from synapseclient.models.mixins.form import FormChangeRequest from synapseclient.models.mixins.form import FormData as FormDataMixin from synapseclient.models.mixins.form import FormGroup as FormGroupMixin +from synapseclient.models.mixins.form import StateEnum @dataclass @@ -129,3 +130,128 @@ async def create_my_form_data(): form_change_request=form_change_request, ) return self.fill_from_dict(response) + + @skip_async_to_sync + async def list_reviewer_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[list["StateEnum"]] = None, + ) -> AsyncGenerator["FormData", None]: + """ + List FormData objects in a FormGroup for review. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. + Must include at least one element. Valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Examples: List all reviewed forms (accepted and rejected) + + Yields: + A page of FormData objects matching the request + + ```python + async def list_reviewed_forms(): + syn = Synapse() + syn.login() + + async for form_data in FormData(group_id="123").list_reviewer_async( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + + asyncio.run(list_reviewed_forms()) + ``` + """ + from synapseclient.api.form_services import list_form_reviewer_async + from synapseclient.models.mixins.form import StateEnum + + if not self.group_id: + raise ValueError("'group_id' must be provided to list FormData.") + + if filter_by_state is None: + filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + + gen = list_form_reviewer_async( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) + async for item in gen: + yield self.fill_from_dict(item) + + def list_reviewer( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[list["StateEnum"]] = None, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup for review. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. Defaults to [StateEnum.SUBMITTED_WAITING_FOR_REVIEW]. + Must include at least one element. Valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A page of FormData objects matching the request + + Examples: List all reviewed forms (accepted and rejected) + + ```python + def list_reviewed_forms(): + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list_reviewer( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + + list_reviewed_forms() + ``` + """ + from synapseclient.api.form_services import list_form_reviewer_sync + + if not self.group_id: + raise ValueError( + "'group_id' must be provided to list form data and their associated status." + ) + + if filter_by_state is None: + filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + + # Validate filter_by_state values + for state in filter_by_state: + if not isinstance(state, StateEnum): + raise ValueError( + "Not a valid instance of stateEnum. Valid values for states are: SUBMITTED_WAITING_FOR_REVIEW, ACCEPTED, or REJECTED." + ) + + if state == StateEnum.WAITING_FOR_SUBMISSION: + raise ValueError( + "StateEnum.WAITING_FOR_SUBMISSION is not allowed for reviewer list. " + "Valid values are: SUBMITTED_WAITING_FOR_REVIEW, ACCEPTED, or REJECTED." + ) + + gen = list_form_reviewer_sync( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) + for item in gen: + yield self.fill_from_dict(item) From 704112e3e1f9cf61dc26278677b764d13f2d24cd Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 4 Dec 2025 17:59:45 -0500 Subject: [PATCH 04/28] make validating state enum separate from individual function; add list form data --- synapseclient/api/form_services.py | 67 ++++++++++- synapseclient/models/form.py | 179 ++++++++++++++++++++++++++--- 2 files changed, 228 insertions(+), 18 deletions(-) diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index b34fbf91b..9ee58cef3 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -68,7 +68,7 @@ async def list_form_reviewer_async( ) -> AsyncGenerator[dict[str, Any], None]: """ - List FormData objects in a FormGroup that are awaiting review. + List FormData objects and their associated status that match the filters of the provided request for the entire group. This is used by service accounts to review submissions. Filtering by WAITING_FOR_SUBMISSION is not allowed for this call. Arguments: synapse_client: The Synapse client to use for the request. @@ -108,7 +108,7 @@ def list_form_reviewer_sync( ) -> Generator[dict[str, Any], None, None]: """ - List FormData objects in a FormGroup that are awaiting review. + List FormData objects and their associated status that match the filters of the provided request for the entire group. This is used by service accounts to review submissions. Filtering by WAITING_FOR_SUBMISSION is not allowed for this call. Arguments: synapse_client: The Synapse client to use for the request. @@ -138,3 +138,66 @@ def list_form_reviewer_sync( }, ): yield item + + +async def list_form_data_async( + synapse_client: "Synapse", + group_id: str, + filter_by_state: Optional[list["StateEnum"]] = None, +) -> AsyncGenerator[dict[str, Any], None]: + """ + + List FormData objects and their associated status that match the filters of the provided request that are owned by the caller. Note: Only objects owned by the caller will be returned. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + + Yields: + A single page of result matching the request + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + async for item in rest_post_paginated_async( + uri="/form/data/list", + body={ + "groupId": group_id, + "filterByState": filter_by_state, + }, + synapse_client=client, + ): + yield item + + +def list_form_data_sync( + synapse_client: "Synapse", + group_id: str, + filter_by_state: Optional[list["StateEnum"]] = None, +) -> Generator[dict[str, Any], None, None]: + """ + + List FormData objects and their associated status that match the filters of the provided request that are owned by the caller. Note: Only objects owned by the caller will be returned. + + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + + Yields: + A single page of result matching the request + + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + for item in client._POST_paginated( + uri="/form/data/list", + body={ + "groupId": group_id, + "filterByState": filter_by_state, + }, + ): + yield item diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 0e2171dff..ac9b8b885 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, AsyncGenerator, Generator, Optional +from typing import TYPE_CHECKING, AsyncGenerator, Generator, List, Optional from synapseclient.core.async_utils import async_to_sync, skip_async_to_sync @@ -64,6 +64,47 @@ async def create_my_form_group(): @dataclass @async_to_sync class FormData(FormDataMixin): + def _validate_filter_by_state( + self, + filter_by_state: Optional[List["StateEnum"]] = None, + allow_waiting_submission: bool = True, + ) -> None: + """ + Validate filter_by_state values. + + Arguments: + filter_by_state: List of StateEnum values to validate. + allow_waiting_submission: If False, raises error if WAITING_FOR_SUBMISSION is present. + + Raises: + ValueError: If filter_by_state contains invalid values. + """ + if not filter_by_state: + return + + # Define valid states based on whether WAITING_FOR_SUBMISSION is allowed + valid_states = { + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + } + if allow_waiting_submission: + valid_states.add(StateEnum.WAITING_FOR_SUBMISSION) + + # Check each state + for state in filter_by_state: + if not isinstance(state, StateEnum): + valid_values = ", ".join(s.value for s in valid_states) + raise ValueError( + f"Invalid state type. Expected StateEnum. Valid values are: {valid_values}" + ) + + if state not in valid_states: + valid_values = ", ".join(s.value for s in valid_states) + raise ValueError( + f"StateEnum.{state.value} is not allowed. Valid values are: {valid_values}" + ) + async def create_async( self, *, @@ -136,7 +177,7 @@ async def list_reviewer_async( self, *, synapse_client: Optional["Synapse"] = None, - filter_by_state: Optional[list["StateEnum"]] = None, + filter_by_state: Optional[List["StateEnum"]] = None, ) -> AsyncGenerator["FormData", None]: """ List FormData objects in a FormGroup for review. @@ -176,6 +217,10 @@ async def list_reviewed_forms(): if not self.group_id: raise ValueError("'group_id' must be provided to list FormData.") + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=False + ) + if filter_by_state is None: filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] @@ -187,11 +232,122 @@ async def list_reviewed_forms(): async for item in gen: yield self.fill_from_dict(item) + @skip_async_to_sync + async def list_async( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + ) -> AsyncGenerator["FormData", None]: + """ + List FormData objects in a FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. + Must include at least one element. Valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A page of FormData objects matching the request + + Examples: List all form data in a group + + ```python + async def list_form_data(): + syn = Synapse() + syn.login() + + async for form_data in FormData(group_id="123").list_async( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + asyncio.run(list_form_data()) + ``` + """ + from synapseclient.api.form_services import list_form_data_async + + if not self.group_id: + raise ValueError("'group_id' must be provided to list FormData.") + + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=True + ) + + gen = list_form_data_async( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) + async for item in gen: + yield self.fill_from_dict(item) + + def list( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. + Must include at least one element. Valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A page of FormData objects matching the request + + Examples: List all form data in a group + + ```python + def list_form_data(): + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + list_form_data() + ``` + """ + from synapseclient.api.form_services import list_form_data_sync + + if not self.group_id: + raise ValueError("'group_id' must be provided to list FormData.") + + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=True + ) + + gen = list_form_data_sync( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) + for item in gen: + yield self.fill_from_dict(item) + def list_reviewer( self, *, synapse_client: Optional["Synapse"] = None, - filter_by_state: Optional[list["StateEnum"]] = None, + filter_by_state: Optional[List["StateEnum"]] = None, ) -> Generator["FormData", None, None]: """ List FormData objects in a FormGroup for review. @@ -232,22 +388,13 @@ def list_reviewed_forms(): "'group_id' must be provided to list form data and their associated status." ) + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=False + ) + if filter_by_state is None: filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - # Validate filter_by_state values - for state in filter_by_state: - if not isinstance(state, StateEnum): - raise ValueError( - "Not a valid instance of stateEnum. Valid values for states are: SUBMITTED_WAITING_FOR_REVIEW, ACCEPTED, or REJECTED." - ) - - if state == StateEnum.WAITING_FOR_SUBMISSION: - raise ValueError( - "StateEnum.WAITING_FOR_SUBMISSION is not allowed for reviewer list. " - "Valid values are: SUBMITTED_WAITING_FOR_REVIEW, ACCEPTED, or REJECTED." - ) - gen = list_form_reviewer_sync( synapse_client=synapse_client, group_id=self.group_id, From bcbb626996a5276503661f5706fb8e35796ae830 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 4 Dec 2025 18:43:15 -0500 Subject: [PATCH 05/28] create a helper function for download --- synapseclient/models/form.py | 59 +++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index ac9b8b885..2eff4f78a 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -1,12 +1,9 @@ -from typing import TYPE_CHECKING, AsyncGenerator, Generator, List, Optional - -from synapseclient.core.async_utils import async_to_sync, skip_async_to_sync - -if TYPE_CHECKING: - from synapseclient import Synapse - +import os from dataclasses import dataclass +from typing import AsyncGenerator, Generator, List, Optional +from synapseclient import Synapse +from synapseclient.core.async_utils import async_to_sync, skip_async_to_sync from synapseclient.models.mixins.form import FormChangeRequest from synapseclient.models.mixins.form import FormData as FormDataMixin from synapseclient.models.mixins.form import FormGroup as FormGroupMixin @@ -402,3 +399,51 @@ def list_reviewed_forms(): ) for item in gen: yield self.fill_from_dict(item) + + async def download_async( + self, + synapse_id, + download_location=None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormData": + """ + Download the data file associated with this FormData object. + + Arguments: + download_location: The directory where the file should be downloaded. + synapse_id: The Synapse ID of the FileEntity associated with this FormData. + + Returns: + The path to the downloaded file. + """ + + from synapseclient.core.download.download_functions import ( + download_by_file_handle, + ensure_download_location_is_directory, + ) + + client = Synapse.get_client(synapse_client=synapse_client) + + if not self.data_file_handle_id: + raise ValueError("data_file_handle_id must be set to download the file.") + + if download_location: + download_dir = ensure_download_location_is_directory( + download_location=download_location + ) + else: + download_dir = client.cache.get_cache_dir( + file_handle_id=self.data_file_handle_id + ) + + filename = f"SYNAPSE_FORM_{self.data_file_handle_id}.csv" + + path = await download_by_file_handle( + file_handle_id=self.data_file_handle_id, + synapse_id=synapse_id, + entity_type="FileEntity", + destination=os.path.join(download_dir, filename), + synapse_client=client, + ) + return path From 56b2b49dd8ecda8e5c1a935b720eb46d5b0c78f4 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 10:24:11 -0500 Subject: [PATCH 06/28] add example; fix type hint --- synapseclient/models/form.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 2eff4f78a..d10ccb0c8 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -402,8 +402,8 @@ def list_reviewed_forms(): async def download_async( self, - synapse_id, - download_location=None, + synapse_id: str, + download_location: Optional[str] = None, *, synapse_client: Optional["Synapse"] = None, ) -> "FormData": @@ -416,6 +416,23 @@ async def download_async( Returns: The path to the downloaded file. + + Examples: Download form data file + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + import asyncio + + async def get_form_data(): + syn = Synapse() + syn.login() + + form_data = await FormData(data_file_handle_id="123456").download_async(synapse_id="syn12345678") + print(form_data) + + asyncio.run(get_form_data()) + ``` """ from synapseclient.core.download.download_functions import ( From d33a0267ed60e68ee01f2dcf810939c132460ff7 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 11:02:10 -0500 Subject: [PATCH 07/28] use protocol; use wrapper --- synapseclient/models/form.py | 58 ++--- .../models/protocols/form_protocol.py | 220 ++++++++++++++++++ 2 files changed, 239 insertions(+), 39 deletions(-) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index d10ccb0c8..fa52c7bf9 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -3,16 +3,24 @@ from typing import AsyncGenerator, Generator, List, Optional from synapseclient import Synapse -from synapseclient.core.async_utils import async_to_sync, skip_async_to_sync +from synapseclient.core.async_utils import ( + async_to_sync, + skip_async_to_sync, + wrap_async_generator_to_sync_generator, +) from synapseclient.models.mixins.form import FormChangeRequest from synapseclient.models.mixins.form import FormData as FormDataMixin from synapseclient.models.mixins.form import FormGroup as FormGroupMixin from synapseclient.models.mixins.form import StateEnum +from synapseclient.models.protocols.form_protocol import ( + FormDataProtocol, + FormGroupProtocol, +) @dataclass @async_to_sync -class FormGroup(FormGroupMixin): +class FormGroup(FormGroupMixin, FormGroupProtocol): async def create_async( self, *, @@ -22,7 +30,6 @@ async def create_async( Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. Arguments: - name: A globally unique name for the group. Required. Between 3 and 256 characters. synapse_client: Optional Synapse client instance for authentication. Returns: @@ -60,7 +67,7 @@ async def create_my_form_group(): @dataclass @async_to_sync -class FormData(FormDataMixin): +class FormData(FormDataMixin, FormDataProtocol): def _validate_filter_by_state( self, filter_by_state: Optional[List["StateEnum"]] = None, @@ -127,7 +134,7 @@ async def create_my_form_data(): syn = Synapse() syn.login() - file = File(id="syn123", download_file=True).get() + file = File(id="syn123", download_file=True).get_async() file_handle_id = file.file_handle.id form_data = FormData( @@ -323,22 +330,11 @@ def list_form_data(): list_form_data() ``` """ - from synapseclient.api.form_services import list_form_data_sync - - if not self.group_id: - raise ValueError("'group_id' must be provided to list FormData.") - - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=True - ) - - gen = list_form_data_sync( - synapse_client=synapse_client, - group_id=self.group_id, + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_async, filter_by_state=filter_by_state, + synapse_client=synapse_client, ) - for item in gen: - yield self.fill_from_dict(item) def list_reviewer( self, @@ -378,27 +374,11 @@ def list_reviewed_forms(): list_reviewed_forms() ``` """ - from synapseclient.api.form_services import list_form_reviewer_sync - - if not self.group_id: - raise ValueError( - "'group_id' must be provided to list form data and their associated status." - ) - - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=False - ) - - if filter_by_state is None: - filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - - gen = list_form_reviewer_sync( - synapse_client=synapse_client, - group_id=self.group_id, + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_reviewer_async, filter_by_state=filter_by_state, + synapse_client=synapse_client, ) - for item in gen: - yield self.fill_from_dict(item) async def download_async( self, @@ -406,7 +386,7 @@ async def download_async( download_location: Optional[str] = None, *, synapse_client: Optional["Synapse"] = None, - ) -> "FormData": + ) -> str: """ Download the data file associated with this FormData object. diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py index e69de29bb..007a7dbcd 100644 --- a/synapseclient/models/protocols/form_protocol.py +++ b/synapseclient/models/protocols/form_protocol.py @@ -0,0 +1,220 @@ +from typing import TYPE_CHECKING, Generator, List, Optional, Protocol + +if TYPE_CHECKING: + from synapseclient import Synapse + from synapseclient.models.mixins import ( + FormGroup, + FormData, + ) + from synapseclient.models.mixins.form import StateEnum + +from synapseclient.core.async_utils import wrap_async_generator_to_sync_generator + + +class FormGroupProtocol(Protocol): + """Protocol for FormGroup operations.""" + + def create( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormGroup": + """ + Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. + + Arguments: + name: A globally unique name for the group. Required. Between 3 and 256 characters. + synapse_client: Optional Synapse client instance for authentication. + + Returns: + A FormGroup object containing the details of the created group. + + Examples: create a form group + + ```python + from synapseclient import Synapse + from synapseclient.models import FormGroup + + syn = Synapse() + syn.login() + + form_group = FormGroup(name="my_unique_form_group_name") + form_group = form_group.create() + print(form_group) + ``` + """ + return FormGroup() + + +class FormDataProtocol(Protocol): + """Protocol for FormData operations.""" + + def create( + self, + *, + synapse_client: Optional["Synapse"] = None, + ) -> "FormData": + """ + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Arguments: + synapse_client: The Synapse client to use for the request. + + Returns: + A FormData object containing the details of the created form data. + + Examples: create a form data + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData, File + + syn = Synapse() + syn.login() + + file = File(id="syn123", download_file=True).get() + file_handle_id = file.file_handle.id + + form_data = FormData( + group_id="123", + name="my_form_data_name", + data_file_handle_id=file_handle_id + ) + form_data = form_data.create() + + print(f"Created FormData: {form_data.form_data_id}") + print(f"Name: {form_data.name}") + print(f"Group ID: {form_data.group_id}") + print(f"Created By: {form_data.created_by}") + print(f"Created On: {form_data.created_on}") + print(f"Data File Handle ID: {form_data.data_file_handle_id}") + + if form_data.submission_status: + print(f"Submission State: {form_data.submission_status.state.value}") + ``` + """ + return FormData() + + def list( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. + Must include at least one element. Valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A page of FormData objects matching the request + + Examples: List all form data in a group + + ```python + def list_form_data(): + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + list_form_data() + ``` + """ + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_async, + filter_by_state=filter_by_state, + synapse_client=synapse_client, + ) + + def list_reviewer( + self, + *, + synapse_client: Optional["Synapse"] = None, + filter_by_state: Optional[List["StateEnum"]] = None, + ) -> Generator["FormData", None, None]: + """ + List FormData objects in a FormGroup for review. + + Arguments: + synapse_client: The Synapse client to use for the request. + filter_by_state: Optional list of StateEnum to filter the results. Defaults to [StateEnum.SUBMITTED_WAITING_FOR_REVIEW]. + Must include at least one element. Valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + + Yields: + A page of FormData objects matching the request + + Examples: List all reviewed forms (accepted and rejected) + + ```python + def list_reviewed_forms(): + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list_reviewer( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + + list_reviewed_forms() + ``` + """ + yield from wrap_async_generator_to_sync_generator( + async_gen_func=self.list_reviewer_async, + filter_by_state=filter_by_state, + synapse_client=synapse_client, + ) + + async def download( + self, + synapse_id: str, + download_location: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, + ) -> str: + """ + Download the data file associated with this FormData object. + + Arguments: + download_location: The directory where the file should be downloaded. + synapse_id: The Synapse ID of the FileEntity associated with this FormData. + + Returns: + The path to the downloaded file. + + Examples: Download form data file + + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + import asyncio + + def get_form_data(): + syn = Synapse() + syn.login() + + form_data = FormData(data_file_handle_id="123456").download(synapse_id="syn12345678") + print(form_data) + + get_form_data() + ``` + """ + return str() From a98ca19252c1ce13c144fa3b09f43909eb8b2f30 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 11:27:43 -0500 Subject: [PATCH 08/28] adjust space; add documentation --- docs/reference/experimental/async/form.md | 23 +++++++++ .../experimental/mixins/form_data.md | 1 + .../experimental/mixins/form_group.md | 1 + docs/reference/experimental/sync/form.md | 23 +++++++++ mkdocs.yml | 4 ++ synapseclient/models/form.py | 48 +++++++++---------- 6 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 docs/reference/experimental/async/form.md create mode 100644 docs/reference/experimental/mixins/form_data.md create mode 100644 docs/reference/experimental/mixins/form_group.md create mode 100644 docs/reference/experimental/sync/form.md diff --git a/docs/reference/experimental/async/form.md b/docs/reference/experimental/async/form.md new file mode 100644 index 000000000..b234dd7c3 --- /dev/null +++ b/docs/reference/experimental/async/form.md @@ -0,0 +1,23 @@ +# FormGroup and Form + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.FormGroup + options: + inherited_members: true + members: + - create_async + + +::: synapseclient.models.FormData + options: + inherited_members: true + members: + - create_async + - list_async + - list_reviewer_async + - download_async diff --git a/docs/reference/experimental/mixins/form_data.md b/docs/reference/experimental/mixins/form_data.md new file mode 100644 index 000000000..f886be46c --- /dev/null +++ b/docs/reference/experimental/mixins/form_data.md @@ -0,0 +1 @@ +::: synapseclient.models.mixins.FormData diff --git a/docs/reference/experimental/mixins/form_group.md b/docs/reference/experimental/mixins/form_group.md new file mode 100644 index 000000000..dfcd5cb6d --- /dev/null +++ b/docs/reference/experimental/mixins/form_group.md @@ -0,0 +1 @@ +::: synapseclient.models.mixins.FormGroup diff --git a/docs/reference/experimental/sync/form.md b/docs/reference/experimental/sync/form.md new file mode 100644 index 000000000..b32c0147a --- /dev/null +++ b/docs/reference/experimental/sync/form.md @@ -0,0 +1,23 @@ +# FormGroup and Form + +Contained within this file are experimental interfaces for working with the Synapse Python +Client. Unless otherwise noted these interfaces are subject to change at any time. Use +at your own risk. + +## API Reference + +::: synapseclient.models.FormGroup + options: + inherited_members: true + members: + - create + + +::: synapseclient.models.FormData + options: + inherited_members: true + members: + - create + - list + - list_reviewer + - download diff --git a/mkdocs.yml b/mkdocs.yml index 062e78769..e3d2b0c1a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -105,6 +105,7 @@ nav: - Functional Interfaces: reference/experimental/functional_interfaces.md - SchemaOrganization: reference/experimental/sync/schema_organization.md - JSONSchema: reference/experimental/sync/json_schema.md + - FormGroup and Form: reference/experimental/sync/form.md - Extensions: - Curator: reference/extensions/curator.md - Asynchronous: @@ -128,6 +129,7 @@ nav: - Link: reference/experimental/async/link_entity.md - SchemaOrganization: reference/experimental/async/schema_organization.md - JSONSchema: reference/experimental/async/json_schema.md + - FormGroup and Form: reference/experimental/async/form.md - Mixins: - AccessControllable: reference/experimental/mixins/access_controllable.md - StorableContainer: reference/experimental/mixins/storable_container.md @@ -135,6 +137,8 @@ nav: - FailureStrategy: reference/experimental/mixins/failure_strategy.md - BaseJSONSchema: reference/experimental/mixins/base_json_schema.md - ContainerEntityJSONSchema: reference/experimental/mixins/container_json_schema.md + - FormData: reference/experimental/mixins/form_data.md + - FormGroup: reference/experimental/mixins/form_group.md - Further Reading: - Home: explanations/home.md diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index fa52c7bf9..e04f464ba 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -261,18 +261,18 @@ async def list_async( Examples: List all form data in a group ```python - async def list_form_data(): - syn = Synapse() - syn.login() - - async for form_data in FormData(group_id="123").list_async( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") - asyncio.run(list_form_data()) + async def list_form_data(): + syn = Synapse() + syn.login() + + async for form_data in FormData(group_id="123").list_async( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + asyncio.run(list_form_data()) ``` """ from synapseclient.api.form_services import list_form_data_async @@ -316,18 +316,18 @@ def list( Examples: List all form data in a group ```python - def list_form_data(): - syn = Synapse() - syn.login() - - for form_data in FormData(group_id="123").list( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") - list_form_data() + def list_form_data(): + syn = Synapse() + syn.login() + + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + list_form_data() ``` """ yield from wrap_async_generator_to_sync_generator( From e846755abb086ce487ea84cbf5728fd5908ae93a Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 11:58:10 -0500 Subject: [PATCH 09/28] export more functions; adjust import --- synapseclient/api/__init__.py | 10 +++++++++- synapseclient/models/form.py | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index d46d099c5..de8b83ba8 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -87,7 +87,12 @@ put_file_multipart_add, put_file_multipart_complete, ) -from .form_services import create_form_group_async +from .form_services import ( + create_form_data_async, + create_form_group_async, + list_form_data_async, + list_form_reviewer_async, +) from .json_schema_services import ( bind_json_schema_to_entity, create_organization, @@ -285,4 +290,7 @@ "get_evaluation_permissions", # form services "create_form_group_async", + "create_form_data_async", + "list_form_reviewer_async", + "list_form_data_async", ] diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index e04f464ba..faf627d74 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -158,7 +158,7 @@ async def create_my_form_data(): ``` """ - from synapseclient.api.form_services import create_form_data_async + from synapseclient.api import create_form_data_async if not self.group_id or not self.name or not self.data_file_handle_id: raise ValueError( @@ -215,7 +215,7 @@ async def list_reviewed_forms(): asyncio.run(list_reviewed_forms()) ``` """ - from synapseclient.api.form_services import list_form_reviewer_async + from synapseclient.api import list_form_reviewer_async from synapseclient.models.mixins.form import StateEnum if not self.group_id: @@ -275,7 +275,7 @@ async def list_form_data(): asyncio.run(list_form_data()) ``` """ - from synapseclient.api.form_services import list_form_data_async + from synapseclient.api import list_form_data_async if not self.group_id: raise ValueError("'group_id' must be provided to list FormData.") From 7fa49eb097f3023a184b52078d6e35515a08009f Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 12:33:52 -0500 Subject: [PATCH 10/28] make imports better; add import in the examples; add test --- synapseclient/models/form.py | 25 ++- synapseclient/models/mixins/__init__.py | 2 + .../synapseclient/mixins/unit_test_form.py | 142 ++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 tests/unit/synapseclient/mixins/unit_test_form.py diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index faf627d74..45b9eca59 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -8,10 +8,10 @@ skip_async_to_sync, wrap_async_generator_to_sync_generator, ) -from synapseclient.models.mixins.form import FormChangeRequest -from synapseclient.models.mixins.form import FormData as FormDataMixin -from synapseclient.models.mixins.form import FormGroup as FormGroupMixin -from synapseclient.models.mixins.form import StateEnum +from synapseclient.models.mixins import FormChangeRequest +from synapseclient.models.mixins import FormData as FormDataMixin +from synapseclient.models.mixins import FormGroup as FormGroupMixin +from synapseclient.models.mixins import StateEnum from synapseclient.models.protocols.form_protocol import ( FormDataProtocol, FormGroupProtocol, @@ -200,6 +200,7 @@ async def list_reviewer_async( A page of FormData objects matching the request ```python + async def list_reviewed_forms(): syn = Synapse() syn.login() @@ -261,6 +262,11 @@ async def list_async( Examples: List all form data in a group ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins import StateEnum + import asyncio + async def list_form_data(): syn = Synapse() syn.login() @@ -316,6 +322,12 @@ def list( Examples: List all form data in a group ```python + + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins import StateEnum + import asyncio + def list_form_data(): syn = Synapse() syn.login() @@ -359,6 +371,11 @@ def list_reviewer( Examples: List all reviewed forms (accepted and rejected) ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins import StateEnum + import asyncio + def list_reviewed_forms(): syn = Synapse() syn.login() diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index 12d6f7dbe..b6c0e32c8 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -6,6 +6,7 @@ FormChangeRequest, FormData, FormGroup, + StateEnum, SubmissionStatus, ) from synapseclient.models.mixins.json_schema import ( @@ -38,4 +39,5 @@ "FormData", "FormChangeRequest", "SubmissionStatus", + "StateEnum", ] diff --git a/tests/unit/synapseclient/mixins/unit_test_form.py b/tests/unit/synapseclient/mixins/unit_test_form.py new file mode 100644 index 000000000..8845ce88b --- /dev/null +++ b/tests/unit/synapseclient/mixins/unit_test_form.py @@ -0,0 +1,142 @@ +from synapseclient.models.mixins import ( + FormChangeRequest, + FormData, + FormGroup, + StateEnum, + SubmissionStatus, +) + + +class TestFormGroupMixin: + def test_fill_from_dict_with_valid_data(self) -> None: + """Test fill_from_dict with all fields populated""" + response_dict = { + "groupId": "12345", + "name": "Test Form Group", + "createdBy": "67890", + "createdOn": "2024-01-01T00:00:00.000Z", + } + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id == "12345" + assert properties.name == "Test Form Group" + assert properties.created_by == "67890" + assert properties.created_on == "2024-01-01T00:00:00.000Z" + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with some missing fields""" + response_dict = { + "groupId": "12345", + # 'name' is missing + "createdBy": "67890", + # 'createdOn' is missing + } + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id == "12345" + assert properties.name is None + assert properties.created_by == "67890" + assert properties.created_on is None + + def test_fill_from_dict_empty_dict(self) -> None: + """Test fill_from_dict with an empty dictionary""" + response_dict = {} + + properties = FormGroup().fill_from_dict(response_dict) + + assert properties.group_id is None + assert properties.name is None + assert properties.created_by is None + assert properties.created_on is None + + +class TestFormChangeRequest: + """Unit tests for FormChangeRequest.to_dict()""" + + def test_to_dict_with_all_fields(self): + """Test to_dict with all fields populated""" + # GIVEN a FormChangeRequest with all fields + form_request = FormChangeRequest(name="my_form_name", file_handle_id="123456") + + # WHEN converting to dict + result = form_request.to_dict() + + # THEN all fields should be present + assert result == {"name": "my_form_name", "fileHandleId": "123456"} + + +class TestSubmissionStatus: + """Unit tests for SubmissionStatus dataclass""" + + def test_submission_status_initialization(self) -> None: + """Test initialization of SubmissionStatus with all fields""" + status = SubmissionStatus( + submitted_on="2024-01-01T00:00:00.000Z", + reviewed_on="2024-01-02T00:00:00.000Z", + reviewed_by="user_123", + ) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert status.reviewed_by == "user_123" + + def test_fill_from_dict(self) -> None: + """Test fill_from_dict method of SubmissionStatus""" + response_dict = { + "submittedOn": "2024-01-01T00:00:00.000Z", + "reviewedOn": "2024-01-02T00:00:00.000Z", + "reviewedBy": "user_123", + } + + status = SubmissionStatus().fill_from_dict(response_dict) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert status.reviewed_by == "user_123" + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with missing fields""" + response_dict = { + "submittedOn": "2024-01-01T00:00:00.000Z" + # 'reviewedOn' and 'reviewedBy' are missing + } + + status = SubmissionStatus().fill_from_dict(response_dict) + + assert status.submitted_on == "2024-01-01T00:00:00.000Z" + assert status.reviewed_on is None + assert status.reviewed_by is None + + +class TestFormDataMixin: + def test_fill_from_dict_with_valid_data(self) -> None: + """Test fill_from_dict with all fields populated""" + response_dict = { + "formDataId": "54321", + "groupId": "12345", + "name": "Test Form Data", + "dataFileHandleId": "67890", + "submissionStatus": { + "submittedOn": "2024-01-01T00:00:00.000Z", + "reviewedOn": "2024-01-02T00:00:00.000Z", + "reviewedBy": "user_123", + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "rejectionMessage": None, + }, + } + + form_data = FormData().fill_from_dict(response_dict) + + assert form_data.form_data_id == "54321" + assert form_data.group_id == "12345" + assert form_data.name == "Test Form Data" + assert form_data.data_file_handle_id == "67890" + assert form_data.submission_status.submitted_on == "2024-01-01T00:00:00.000Z" + assert form_data.submission_status.reviewed_on == "2024-01-02T00:00:00.000Z" + assert form_data.submission_status.reviewed_by == "user_123" + assert ( + form_data.submission_status.state == StateEnum.SUBMITTED_WAITING_FOR_REVIEW + ) + assert form_data.submission_status.rejection_message is None From 61b4d8451213cdbc0541ad26857ee66fe72fcd10 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 12:34:46 -0500 Subject: [PATCH 11/28] add mixin test --- .../synapseclient/mixins/unit_test_form.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/synapseclient/mixins/unit_test_form.py b/tests/unit/synapseclient/mixins/unit_test_form.py index 8845ce88b..6e6d6df13 100644 --- a/tests/unit/synapseclient/mixins/unit_test_form.py +++ b/tests/unit/synapseclient/mixins/unit_test_form.py @@ -140,3 +140,21 @@ def test_fill_from_dict_with_valid_data(self) -> None: form_data.submission_status.state == StateEnum.SUBMITTED_WAITING_FOR_REVIEW ) assert form_data.submission_status.rejection_message is None + + def test_fill_from_dict_missing_fields(self) -> None: + """Test fill_from_dict with some missing fields""" + response_dict = { + "formDataId": "54321", + # 'groupId' is missing + "name": "Test Form Data", + # 'dataFileHandleId' is missing + # 'submissionStatus' is missing + } + + form_data = FormData().fill_from_dict(response_dict) + + assert form_data.form_data_id == "54321" + assert form_data.group_id is None + assert form_data.name == "Test Form Data" + assert form_data.data_file_handle_id is None + assert form_data.submission_status is None From 82e0033a59286b4ddbac8aae6ed6318d6052f704 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 12:57:15 -0500 Subject: [PATCH 12/28] update import --- synapseclient/models/form.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 45b9eca59..a2a4ba52d 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -169,6 +169,7 @@ async def create_my_form_data(): name=self.name, file_handle_id=self.data_file_handle_id ).to_dict() + print(form_change_request) response = await create_form_data_async( synapse_client=synapse_client, group_id=self.group_id, From c4e05a72c32e987cec7d72afc44588aaee5bf615 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 14:11:55 -0500 Subject: [PATCH 13/28] combining list data and list reviewer --- docs/reference/experimental/async/form.md | 1 - docs/reference/experimental/sync/form.md | 1 - synapseclient/models/form.py | 261 ++++++++---------- .../models/protocols/form_protocol.py | 126 ++++----- 4 files changed, 180 insertions(+), 209 deletions(-) diff --git a/docs/reference/experimental/async/form.md b/docs/reference/experimental/async/form.md index b234dd7c3..da99f4a6a 100644 --- a/docs/reference/experimental/async/form.md +++ b/docs/reference/experimental/async/form.md @@ -19,5 +19,4 @@ at your own risk. members: - create_async - list_async - - list_reviewer_async - download_async diff --git a/docs/reference/experimental/sync/form.md b/docs/reference/experimental/sync/form.md index b32c0147a..8c9f8b37d 100644 --- a/docs/reference/experimental/sync/form.md +++ b/docs/reference/experimental/sync/form.md @@ -19,5 +19,4 @@ at your own risk. members: - create - list - - list_reviewer - download diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index a2a4ba52d..5c1a7c63c 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -8,10 +8,10 @@ skip_async_to_sync, wrap_async_generator_to_sync_generator, ) -from synapseclient.models.mixins import FormChangeRequest -from synapseclient.models.mixins import FormData as FormDataMixin -from synapseclient.models.mixins import FormGroup as FormGroupMixin -from synapseclient.models.mixins import StateEnum +from synapseclient.models.mixins.form import FormChangeRequest +from synapseclient.models.mixins.form import FormData as FormDataMixin +from synapseclient.models.mixins.form import FormGroup as FormGroupMixin +from synapseclient.models.mixins.form import StateEnum from synapseclient.models.protocols.form_protocol import ( FormDataProtocol, FormGroupProtocol, @@ -134,7 +134,7 @@ async def create_my_form_data(): syn = Synapse() syn.login() - file = File(id="syn123", download_file=True).get_async() + file = await File(id="syn123", download_file=True).get_async() file_handle_id = file.file_handle.id form_data = FormData( @@ -169,7 +169,6 @@ async def create_my_form_data(): name=self.name, file_handle_id=self.data_file_handle_id ).to_dict() - print(form_change_request) response = await create_form_data_async( synapse_client=synapse_client, group_id=self.group_id, @@ -178,35 +177,53 @@ async def create_my_form_data(): return self.fill_from_dict(response) @skip_async_to_sync - async def list_reviewer_async( + async def list_async( self, *, synapse_client: Optional["Synapse"] = None, filter_by_state: Optional[List["StateEnum"]] = None, + as_reviewer: bool = False, ) -> AsyncGenerator["FormData", None]: """ - List FormData objects in a FormGroup for review. + List FormData objects in a FormGroup. Arguments: synapse_client: The Synapse client to use for the request. filter_by_state: Optional list of StateEnum to filter the results. - Must include at least one element. Valid values are: + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED - Examples: List all reviewed forms (accepted and rejected) + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint (requires READ_PRIVATE_SUBMISSION + permission). If False (default), lists only FormData owned by the caller. Yields: - A page of FormData objects matching the request + FormData objects matching the request. + + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. + + Examples: List your own form data ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + import asyncio - async def list_reviewed_forms(): + async def list_my_form_data(): syn = Synapse() syn.login() - async for form_data in FormData(group_id="123").list_reviewer_async( + async for form_data in FormData(group_id="123").list_async( filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] ): status = form_data.submission_status @@ -214,88 +231,66 @@ async def list_reviewed_forms(): print(f"State: {status.state.value}") print(f"Submitted on: {status.submitted_on}") - asyncio.run(list_reviewed_forms()) + asyncio.run(list_my_form_data()) ``` - """ - from synapseclient.api import list_form_reviewer_async - from synapseclient.models.mixins.form import StateEnum - if not self.group_id: - raise ValueError("'group_id' must be provided to list FormData.") - - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=False - ) - - if filter_by_state is None: - filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - - gen = list_form_reviewer_async( - synapse_client=synapse_client, - group_id=self.group_id, - filter_by_state=filter_by_state, - ) - async for item in gen: - yield self.fill_from_dict(item) - - @skip_async_to_sync - async def list_async( - self, - *, - synapse_client: Optional["Synapse"] = None, - filter_by_state: Optional[List["StateEnum"]] = None, - ) -> AsyncGenerator["FormData", None]: - """ - List FormData objects in a FormGroup. - - Arguments: - synapse_client: The Synapse client to use for the request. - filter_by_state: Optional list of StateEnum to filter the results. - Must include at least one element. Valid values are: - - StateEnum.WAITING_FOR_SUBMISSION - - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - - StateEnum.ACCEPTED - - StateEnum.REJECTED - - Yields: - A page of FormData objects matching the request - - Examples: List all form data in a group + Examples: List all form data as a reviewer ```python from synapseclient import Synapse from synapseclient.models import FormData - from synapseclient.models.mixins import StateEnum + from synapseclient.models.mixins.form import StateEnum import asyncio - async def list_form_data(): + async def list_for_review(): syn = Synapse() syn.login() + # List all submissions waiting for review (reviewer mode) async for form_data in FormData(group_id="123").list_async( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] ): status = form_data.submission_status print(f"Form name: {form_data.name}") print(f"State: {status.state.value}") print(f"Submitted on: {status.submitted_on}") - asyncio.run(list_form_data()) + + asyncio.run(list_for_review()) ``` """ - from synapseclient.api import list_form_data_async + from synapseclient.api import list_form_data_async, list_form_reviewer_async if not self.group_id: raise ValueError("'group_id' must be provided to list FormData.") - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=True - ) + # Validate filter_by_state based on reviewer mode + if as_reviewer: + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=False + ) + # Default to SUBMITTED_WAITING_FOR_REVIEW for reviewer mode + if filter_by_state is None: + filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + + # Use reviewer endpoint + gen = list_form_reviewer_async( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) + else: + self._validate_filter_by_state( + filter_by_state=filter_by_state, allow_waiting_submission=True + ) + + # Use regular endpoint + gen = list_form_data_async( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + ) - gen = list_form_data_async( - synapse_client=synapse_client, - group_id=self.group_id, - filter_by_state=filter_by_state, - ) async for item in gen: yield self.fill_from_dict(item) @@ -304,6 +299,7 @@ def list( *, synapse_client: Optional["Synapse"] = None, filter_by_state: Optional[List["StateEnum"]] = None, + as_reviewer: bool = False, ) -> Generator["FormData", None, None]: """ List FormData objects in a FormGroup. @@ -311,91 +307,72 @@ def list( Arguments: synapse_client: The Synapse client to use for the request. filter_by_state: Optional list of StateEnum to filter the results. - Must include at least one element. Valid values are: + When as_reviewer=False (default), valid values are: - StateEnum.WAITING_FOR_SUBMISSION - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint (requires READ_PRIVATE_SUBMISSION + permission). If False (default), lists only FormData owned by the caller. + Yields: - A page of FormData objects matching the request + FormData objects matching the request. - Examples: List all form data in a group + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. - ```python + Examples: List your own form data + ```python from synapseclient import Synapse from synapseclient.models import FormData - from synapseclient.models.mixins import StateEnum - import asyncio + from synapseclient.models.mixins.form import StateEnum - def list_form_data(): - syn = Synapse() - syn.login() + syn = Synapse() + syn.login() - for form_data in FormData(group_id="123").list( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") - list_form_data() + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") ``` - """ - yield from wrap_async_generator_to_sync_generator( - async_gen_func=self.list_async, - filter_by_state=filter_by_state, - synapse_client=synapse_client, - ) - def list_reviewer( - self, - *, - synapse_client: Optional["Synapse"] = None, - filter_by_state: Optional[List["StateEnum"]] = None, - ) -> Generator["FormData", None, None]: - """ - List FormData objects in a FormGroup for review. - - Arguments: - synapse_client: The Synapse client to use for the request. - filter_by_state: Optional list of StateEnum to filter the results. Defaults to [StateEnum.SUBMITTED_WAITING_FOR_REVIEW]. - Must include at least one element. Valid values are: - - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - - StateEnum.ACCEPTED - - StateEnum.REJECTED - - Yields: - A page of FormData objects matching the request - - Examples: List all reviewed forms (accepted and rejected) + Examples: List all form data as a reviewer ```python from synapseclient import Synapse from synapseclient.models import FormData - from synapseclient.models.mixins import StateEnum - import asyncio - - def list_reviewed_forms(): - syn = Synapse() - syn.login() - - for form_data in FormData(group_id="123").list_reviewer( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") + from synapseclient.models.mixins.form import StateEnum - list_reviewed_forms() + syn = Synapse() + syn.login() + + # List all submissions waiting for review (reviewer mode) + for form_data in FormData(group_id="123").list( + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") ``` """ yield from wrap_async_generator_to_sync_generator( - async_gen_func=self.list_reviewer_async, - filter_by_state=filter_by_state, + async_gen_func=self.list_async, synapse_client=synapse_client, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, ) async def download_async( @@ -409,8 +386,9 @@ async def download_async( Download the data file associated with this FormData object. Arguments: + synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). download_location: The directory where the file should be downloaded. - synapse_id: The Synapse ID of the FileEntity associated with this FormData. + synapse_client: The Synapse client to use for the request. Returns: The path to the downloaded file. @@ -418,18 +396,19 @@ async def download_async( Examples: Download form data file ```python - from synapseclient import Synapse - from synapseclient.models import FormData - import asyncio - - async def get_form_data(): + async def download_form_data(): syn = Synapse() syn.login() - form_data = await FormData(data_file_handle_id="123456").download_async(synapse_id="syn12345678") - print(form_data) + form_data = await FormData(form_data_id="123").get_async() # Step 1: Get FormData + path = await form_data.download_async( # Step 2: Download file + synapse_id="syn12345678", + download_location="/tmp" + ) + print(f"Downloaded to: {path}") # Print the path + - asyncio.run(get_form_data()) + asyncio.run(download_form_data()) ``` """ diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py index 007a7dbcd..02ddd2af9 100644 --- a/synapseclient/models/protocols/form_protocol.py +++ b/synapseclient/models/protocols/form_protocol.py @@ -100,6 +100,7 @@ def list( *, synapse_client: Optional["Synapse"] = None, filter_by_state: Optional[List["StateEnum"]] = None, + as_reviewer: bool = False, ) -> Generator["FormData", None, None]: """ List FormData objects in a FormGroup. @@ -107,83 +108,75 @@ def list( Arguments: synapse_client: The Synapse client to use for the request. filter_by_state: Optional list of StateEnum to filter the results. - Must include at least one element. Valid values are: + When as_reviewer=False (default), valid values are: - StateEnum.WAITING_FOR_SUBMISSION - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint (requires READ_PRIVATE_SUBMISSION + permission). If False (default), lists only FormData owned by the caller. + Yields: - A page of FormData objects matching the request + FormData objects matching the request. - Examples: List all form data in a group + Raises: + ValueError: If group_id is not set or filter_by_state contains invalid values. - ```python - def list_form_data(): - syn = Synapse() - syn.login() - - for form_data in FormData(group_id="123").list( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.ACCEPTED, StateEnum.REJECTED, StateEnum.WAITING_FOR_SUBMISSION] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") - list_form_data() - ``` - """ - yield from wrap_async_generator_to_sync_generator( - async_gen_func=self.list_async, - filter_by_state=filter_by_state, - synapse_client=synapse_client, - ) + Examples: List your own form data - def list_reviewer( - self, - *, - synapse_client: Optional["Synapse"] = None, - filter_by_state: Optional[List["StateEnum"]] = None, - ) -> Generator["FormData", None, None]: - """ - List FormData objects in a FormGroup for review. + ```python + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum - Arguments: - synapse_client: The Synapse client to use for the request. - filter_by_state: Optional list of StateEnum to filter the results. Defaults to [StateEnum.SUBMITTED_WAITING_FOR_REVIEW]. - Must include at least one element. Valid values are: - - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - - StateEnum.ACCEPTED - - StateEnum.REJECTED + syn = Synapse() + syn.login() - Yields: - A page of FormData objects matching the request + for form_data in FormData(group_id="123").list( + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") + ``` - Examples: List all reviewed forms (accepted and rejected) + Examples: List all form data as a reviewer ```python - def list_reviewed_forms(): - syn = Synapse() - syn.login() - - for form_data in FormData(group_id="123").list_reviewer( - filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - ): - status = form_data.submission_status - print(f"Form name: {form_data.name}") - print(f"State: {status.state.value}") - print(f"Submitted on: {status.submitted_on}") - - list_reviewed_forms() + from synapseclient import Synapse + from synapseclient.models import FormData + from synapseclient.models.mixins.form import StateEnum + + syn = Synapse() + syn.login() + + # List all submissions waiting for review (reviewer mode) + for form_data in FormData(group_id="123").list( + as_reviewer=True, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW] + ): + status = form_data.submission_status + print(f"Form name: {form_data.name}") + print(f"State: {status.state.value}") + print(f"Submitted on: {status.submitted_on}") ``` """ yield from wrap_async_generator_to_sync_generator( - async_gen_func=self.list_reviewer_async, - filter_by_state=filter_by_state, + async_gen_func=self.list_async, synapse_client=synapse_client, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, ) - async def download( + def download( self, synapse_id: str, download_location: Optional[str] = None, @@ -195,7 +188,8 @@ async def download( Arguments: download_location: The directory where the file should be downloaded. - synapse_id: The Synapse ID of the FileEntity associated with this FormData. + synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). + synapse_client: The Synapse client to use for the request. Returns: The path to the downloaded file. @@ -205,16 +199,16 @@ async def download( ```python from synapseclient import Synapse from synapseclient.models import FormData - import asyncio - - def get_form_data(): - syn = Synapse() - syn.login() - form_data = FormData(data_file_handle_id="123456").download(synapse_id="syn12345678") - print(form_data) + syn = Synapse() + syn.login() - get_form_data() + form_data = FormData(form_data_id="123").get() # First get the FormData + path = form_data.download( # Then download + synapse_id="syn12345678", + download_location="/tmp" + ) + print(f"Downloaded to: {path}") ``` """ return str() From e3119ddfcc52b8fb6ea9218fd3565576962f158c Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 14:16:22 -0500 Subject: [PATCH 14/28] raise value error if filter by state is empty --- synapseclient/models/form.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 5c1a7c63c..1a4ee27a0 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -81,10 +81,10 @@ def _validate_filter_by_state( allow_waiting_submission: If False, raises error if WAITING_FOR_SUBMISSION is present. Raises: - ValueError: If filter_by_state contains invalid values. + ValueError: If filter_by_state is empty or contains invalid values. """ if not filter_by_state: - return + raise ValueError("filter_by_state cannot be None or empty.") # Define valid states based on whether WAITING_FOR_SUBMISSION is allowed valid_states = { From ba58ee1ef8a2e17687e12b6aa14758400eafab7f Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 14:26:01 -0500 Subject: [PATCH 15/28] rename to form submission status --- synapseclient/models/mixins/__init__.py | 4 ++-- synapseclient/models/mixins/form.py | 12 +++++++----- .../unit/synapseclient/mixins/unit_test_form.py | 16 ++++++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/synapseclient/models/mixins/__init__.py b/synapseclient/models/mixins/__init__.py index b6c0e32c8..62ddcf017 100644 --- a/synapseclient/models/mixins/__init__.py +++ b/synapseclient/models/mixins/__init__.py @@ -6,8 +6,8 @@ FormChangeRequest, FormData, FormGroup, + FormSubmissionStatus, StateEnum, - SubmissionStatus, ) from synapseclient.models.mixins.json_schema import ( BaseJSONSchema, @@ -38,6 +38,6 @@ "FormGroup", "FormData", "FormChangeRequest", - "SubmissionStatus", + "FormSubmissionStatus", "StateEnum", ] diff --git a/synapseclient/models/mixins/form.py b/synapseclient/models/mixins/form.py index b6fc5c656..c4bd5b9f3 100644 --- a/synapseclient/models/mixins/form.py +++ b/synapseclient/models/mixins/form.py @@ -64,7 +64,7 @@ class StateEnum(str, Enum): @dataclass -class SubmissionStatus: +class FormSubmissionStatus: """ The status of a submitted FormData object. """ @@ -84,7 +84,9 @@ class SubmissionStatus: rejection_message: Optional[str] = None """The message provided by the reviewer when a submission is rejected.""" - def fill_from_dict(self, synapse_response: dict[str, Any]) -> "SubmissionStatus": + def fill_from_dict( + self, synapse_response: dict[str, Any] + ) -> "FormSubmissionStatus": """ Converts a response from the REST API into this dataclass. @@ -92,7 +94,7 @@ def fill_from_dict(self, synapse_response: dict[str, Any]) -> "SubmissionStatus" synapse_response: The response dictionary from the Synapse REST API. Returns: - This SubmissionStatus object with populated fields. + This FormSubmissionStatus object with populated fields. """ self.submitted_on = synapse_response.get("submittedOn", None) self.reviewed_on = synapse_response.get("reviewedOn", None) @@ -139,7 +141,7 @@ class FormData: data_file_handle_id: Optional[str] = None """The identifier of the data FileHandle for this object.""" - submission_status: Optional[SubmissionStatus] = None + submission_status: Optional[FormSubmissionStatus] = None """The status of a submitted FormData object.""" def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormData": @@ -165,7 +167,7 @@ def fill_from_dict(self, synapse_response: dict[str, Any]) -> "FormData": "submissionStatus" in synapse_response and synapse_response["submissionStatus"] is not None ): - self.submission_status = SubmissionStatus().fill_from_dict( + self.submission_status = FormSubmissionStatus().fill_from_dict( synapse_response["submissionStatus"] ) diff --git a/tests/unit/synapseclient/mixins/unit_test_form.py b/tests/unit/synapseclient/mixins/unit_test_form.py index 6e6d6df13..192ce9b42 100644 --- a/tests/unit/synapseclient/mixins/unit_test_form.py +++ b/tests/unit/synapseclient/mixins/unit_test_form.py @@ -2,8 +2,8 @@ FormChangeRequest, FormData, FormGroup, + FormSubmissionStatus, StateEnum, - SubmissionStatus, ) @@ -67,12 +67,12 @@ def test_to_dict_with_all_fields(self): assert result == {"name": "my_form_name", "fileHandleId": "123456"} -class TestSubmissionStatus: +class TestFormSubmissionStatus: """Unit tests for SubmissionStatus dataclass""" - def test_submission_status_initialization(self) -> None: - """Test initialization of SubmissionStatus with all fields""" - status = SubmissionStatus( + def test_form_submission_status_initialization(self) -> None: + """Test initialization of FormSubmissionStatus with all fields""" + status = FormSubmissionStatus( submitted_on="2024-01-01T00:00:00.000Z", reviewed_on="2024-01-02T00:00:00.000Z", reviewed_by="user_123", @@ -83,14 +83,14 @@ def test_submission_status_initialization(self) -> None: assert status.reviewed_by == "user_123" def test_fill_from_dict(self) -> None: - """Test fill_from_dict method of SubmissionStatus""" + """Test fill_from_dict method of FormSubmissionStatus""" response_dict = { "submittedOn": "2024-01-01T00:00:00.000Z", "reviewedOn": "2024-01-02T00:00:00.000Z", "reviewedBy": "user_123", } - status = SubmissionStatus().fill_from_dict(response_dict) + status = FormSubmissionStatus().fill_from_dict(response_dict) assert status.submitted_on == "2024-01-01T00:00:00.000Z" assert status.reviewed_on == "2024-01-02T00:00:00.000Z" @@ -103,7 +103,7 @@ def test_fill_from_dict_missing_fields(self) -> None: # 'reviewedOn' and 'reviewedBy' are missing } - status = SubmissionStatus().fill_from_dict(response_dict) + status = FormSubmissionStatus().fill_from_dict(response_dict) assert status.submitted_on == "2024-01-01T00:00:00.000Z" assert status.reviewed_on is None From 148f178a9df8561089d9c1f1ef796359bb2b61af Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 15:03:55 -0500 Subject: [PATCH 16/28] edit docstring; just use one call --- synapseclient/api/__init__.py | 2 - synapseclient/api/form_services.py | 188 ++++++++++++----------------- synapseclient/models/form.py | 9 +- 3 files changed, 83 insertions(+), 116 deletions(-) diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index de8b83ba8..6cf9c300c 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -91,7 +91,6 @@ create_form_data_async, create_form_group_async, list_form_data_async, - list_form_reviewer_async, ) from .json_schema_services import ( bind_json_schema_to_entity, @@ -291,6 +290,5 @@ # form services "create_form_group_async", "create_form_data_async", - "list_form_reviewer_async", "list_form_data_async", ] diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index 9ee58cef3..e00ae7648 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, AsyncGenerator, Generator, Optional from synapseclient.api.api_client import rest_post_paginated_async +from synapseclient.core.async_utils import wrap_async_generator_to_sync_generator if TYPE_CHECKING: from synapseclient import Synapse @@ -16,7 +17,7 @@ async def create_form_group_async( Create a form group asynchronously. - Args: + Arguments: synapse_client: The Synapse client to use for the request. name: A globally unique name for the group. Required. Between 3 and 256 characters. @@ -37,17 +38,17 @@ async def create_form_data_async( form_change_request: dict[str, Any], ) -> dict[str, Any]: """ - - Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. + + Create a new FormData object. The caller will own the resulting object and will have access to read, update, and delete the FormData object. - Arguments: - synapse_client: The Synapse client to use for the request. - group_id: The ID of the form group. - form_change_request: a dictionary of form change request matching . + Arguments: + synapse_client: The Synapse client to use for the request. + group_id: The ID of the form group. + form_change_request: a dictionary of form change request matching . - Returns: - A Form data object as a dictionary. - Object matching + Returns: + A Form data object as a dictionary. + Object matching Note: The caller must have the SUBMIT permission on the FormGroup to create/update/submit FormData. """ @@ -61,112 +62,60 @@ async def create_form_data_async( ) -async def list_form_reviewer_async( +async def list_form_data_async( synapse_client: "Synapse", group_id: str, filter_by_state: Optional[list["StateEnum"]] = None, + as_reviewer: bool = False, ) -> AsyncGenerator[dict[str, Any], None]: """ - - List FormData objects and their associated status that match the filters of the provided request for the entire group. This is used by service accounts to review submissions. Filtering by WAITING_FOR_SUBMISSION is not allowed for this call. + List FormData objects and their associated status that match the filters of the provided request. + + When as_reviewer=False: + Returns FormData objects owned by the caller. Only objects owned by the caller will be returned. + + When as_reviewer=True: + Returns FormData objects for the entire group. This is used by service accounts to review submissions. + Requires READ_PRIVATE_SUBMISSION permission on the FormGroup. Arguments: synapse_client: The Synapse client to use for the request. - group_id: The ID of the form group. - filter_by_state: List of StateEnum values to filter the FormData objects. - Must include at least one element. Valid values are: + group_id: The ID of the form group. Required. + filter_by_state: Optional list of StateEnum values to filter the FormData objects. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED + If None, returns all FormData objects. - Yields: - A single page of result matching the request - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) - - if not filter_by_state: - raise ValueError("filter_by_state must include at least one StateEnum value.") - - async for item in rest_post_paginated_async( - uri="/form/data/list/reviewer", - body={ - "groupId": group_id, - "filterByState": filter_by_state, - }, - synapse_client=client, - ): - yield item - - -def list_form_reviewer_sync( - synapse_client: "Synapse", - group_id: str, - filter_by_state: Optional[list["StateEnum"]] = None, -) -> Generator[dict[str, Any], None, None]: - """ - - List FormData objects and their associated status that match the filters of the provided request for the entire group. This is used by service accounts to review submissions. Filtering by WAITING_FOR_SUBMISSION is not allowed for this call. - - Arguments: - synapse_client: The Synapse client to use for the request. - group_id: The ID of the form group. - filter_by_state: List of StateEnum values to filter the FormData objects. - Must include at least one element. Valid values are: - - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) - StateEnum.ACCEPTED - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. + + as_reviewer: If True, uses the reviewer endpoint to list FormData for the entire group. + If False (default), lists only FormData owned by the caller. Yields: - A single page of result matching the request - + A single page of FormData objects matching the request. + Object matching """ from synapseclient import Synapse client = Synapse.get_client(synapse_client=synapse_client) - if not filter_by_state: - raise ValueError("filter_by_state must include at least one StateEnum value.") - - for item in client._POST_paginated( - uri="/form/data/list/reviewer", - body={ - "groupId": group_id, - "filterByState": filter_by_state, - }, - ): - yield item + body: dict[str, Any] = {"groupId": group_id, "filterByState": filter_by_state} - -async def list_form_data_async( - synapse_client: "Synapse", - group_id: str, - filter_by_state: Optional[list["StateEnum"]] = None, -) -> AsyncGenerator[dict[str, Any], None]: - """ - - List FormData objects and their associated status that match the filters of the provided request that are owned by the caller. Note: Only objects owned by the caller will be returned. - - Arguments: - synapse_client: The Synapse client to use for the request. - group_id: The ID of the form group. - - Yields: - A single page of result matching the request - - """ - from synapseclient import Synapse - - client = Synapse.get_client(synapse_client=synapse_client) + if as_reviewer: + uri = "/form/data/list/reviewer" + else: + uri = "/form/data/list" async for item in rest_post_paginated_async( - uri="/form/data/list", - body={ - "groupId": group_id, - "filterByState": filter_by_state, - }, + uri=uri, + body=body, synapse_client=client, ): yield item @@ -176,28 +125,49 @@ def list_form_data_sync( synapse_client: "Synapse", group_id: str, filter_by_state: Optional[list["StateEnum"]] = None, + as_reviewer: bool = False, ) -> Generator[dict[str, Any], None, None]: """ - - List FormData objects and their associated status that match the filters of the provided request that are owned by the caller. Note: Only objects owned by the caller will be returned. + List FormData objects and their associated status that match the filters of the provided request. + + This is the synchronous version of list_form_data_async. + + When as_reviewer=False: + Returns FormData objects owned by the caller. Only objects owned by the caller will be returned. + + When as_reviewer=True: + Returns FormData objects for the entire group. This is used by service accounts to review submissions. + Requires READ_PRIVATE_SUBMISSION permission on the FormGroup. Arguments: synapse_client: The Synapse client to use for the request. - group_id: The ID of the form group. + group_id: The ID of the form group. Required. + filter_by_state: Optional list of StateEnum values to filter the FormData objects. + When as_reviewer=False (default), valid values are: + - StateEnum.WAITING_FOR_SUBMISSION + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW + - StateEnum.ACCEPTED + - StateEnum.REJECTED + If None, returns all FormData objects. - Yields: - A single page of result matching the request - - """ - from synapseclient import Synapse + When as_reviewer=True, valid values are: + - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) + - StateEnum.ACCEPTED + - StateEnum.REJECTED + Note: WAITING_FOR_SUBMISSION is NOT allowed when as_reviewer=True. - client = Synapse.get_client(synapse_client=synapse_client) + as_reviewer: If True, uses the reviewer endpoint to list FormData for the entire group. + If False (default), lists only FormData owned by the caller. - for item in client._POST_paginated( - uri="/form/data/list", - body={ - "groupId": group_id, - "filterByState": filter_by_state, - }, - ): - yield item + Yields: + A single page of FormData objects matching the request. + Object matching + """ + return wrap_async_generator_to_sync_generator( + list_form_data_async( + synapse_client=synapse_client, + group_id=group_id, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + ) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 1a4ee27a0..2ea903f9b 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -259,7 +259,7 @@ async def list_for_review(): asyncio.run(list_for_review()) ``` """ - from synapseclient.api import list_form_data_async, list_form_reviewer_async + from synapseclient.api import list_form_data_async if not self.group_id: raise ValueError("'group_id' must be provided to list FormData.") @@ -269,26 +269,25 @@ async def list_for_review(): self._validate_filter_by_state( filter_by_state=filter_by_state, allow_waiting_submission=False ) - # Default to SUBMITTED_WAITING_FOR_REVIEW for reviewer mode if filter_by_state is None: filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - # Use reviewer endpoint - gen = list_form_reviewer_async( + gen = list_form_data_async( synapse_client=synapse_client, group_id=self.group_id, filter_by_state=filter_by_state, + as_reviewer=True, ) else: self._validate_filter_by_state( filter_by_state=filter_by_state, allow_waiting_submission=True ) - # Use regular endpoint gen = list_form_data_async( synapse_client=synapse_client, group_id=self.group_id, filter_by_state=filter_by_state, + as_reviewer=False, ) async for item in gen: From 2f38b6b953f078462713ecb3f6fa4f9744a61b19 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 5 Dec 2025 15:11:39 -0500 Subject: [PATCH 17/28] remove comments in the docstring --- synapseclient/models/form.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 2ea903f9b..cdeb4230f 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -399,12 +399,12 @@ async def download_form_data(): syn = Synapse() syn.login() - form_data = await FormData(form_data_id="123").get_async() # Step 1: Get FormData - path = await form_data.download_async( # Step 2: Download file + form_data = await FormData(form_data_id="123").get_async() + path = await form_data.download_async( synapse_id="syn12345678", download_location="/tmp" ) - print(f"Downloaded to: {path}") # Print the path + print(f"Downloaded to: {path}") asyncio.run(download_form_data()) From 2a62c484384bb799b8ae41e3aad216a1037c879b Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 11:56:01 -0500 Subject: [PATCH 18/28] add test; simplify code --- synapseclient/models/form.py | 36 +-- .../synapseclient/models/unit_test_form.py | 269 ++++++++++++++++++ 2 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 tests/unit/synapseclient/models/unit_test_form.py diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index cdeb4230f..9b0211543 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -168,7 +168,6 @@ async def create_my_form_data(): form_change_request = FormChangeRequest( name=self.name, file_handle_id=self.data_file_handle_id ).to_dict() - response = await create_form_data_async( synapse_client=synapse_client, group_id=self.group_id, @@ -266,32 +265,23 @@ async def list_for_review(): # Validate filter_by_state based on reviewer mode if as_reviewer: - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=False - ) - if filter_by_state is None: - filter_by_state = [StateEnum.SUBMITTED_WAITING_FOR_REVIEW] - - gen = list_form_data_async( - synapse_client=synapse_client, - group_id=self.group_id, - filter_by_state=filter_by_state, - as_reviewer=True, - ) + allow_waiting_submission = False else: - self._validate_filter_by_state( - filter_by_state=filter_by_state, allow_waiting_submission=True - ) + allow_waiting_submission = True - gen = list_form_data_async( - synapse_client=synapse_client, - group_id=self.group_id, - filter_by_state=filter_by_state, - as_reviewer=False, - ) + self._validate_filter_by_state( + filter_by_state=filter_by_state, + allow_waiting_submission=allow_waiting_submission, + ) + gen = list_form_data_async( + synapse_client=synapse_client, + group_id=self.group_id, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) async for item in gen: - yield self.fill_from_dict(item) + yield FormData().fill_from_dict(item) def list( self, diff --git a/tests/unit/synapseclient/models/unit_test_form.py b/tests/unit/synapseclient/models/unit_test_form.py new file mode 100644 index 000000000..baa52102c --- /dev/null +++ b/tests/unit/synapseclient/models/unit_test_form.py @@ -0,0 +1,269 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models import FormData, FormGroup +from synapseclient.models.mixins import StateEnum + + +class TestFormGroup: + """Unit tests for the FormGroup model.""" + + @pytest.fixture + def syn(self): + """Mock Synapse client""" + mock_syn = MagicMock(spec=Synapse) + return mock_syn + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_group_async""" + return { + "groupId": "12345", + "name": "my_test_form_group", + "createdOn": "2023-12-01T10:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T10:00:00.000Z", + } + + async def test_create_async_success(self, syn, mock_response): + """Test successful form group creation""" + # GIVEN a FormGroup with a name + form_group = FormGroup(name="my_test_form_group") + + # WHEN creating the form group + with patch( + "synapseclient.api.form_services.create_form_group_async", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_create: + result = await form_group.create_async(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create.assert_called_once_with( + synapse_client=syn, + name="my_test_form_group", + ) + + # AND the result should be a FormGroup with populated fields + assert isinstance(result, FormGroup) + assert result.name == "my_test_form_group" + assert result.group_id == "12345" + assert result.created_by == "3350396" + assert result.created_on == "2023-12-01T10:00:00.000Z" + + async def test_create_async_without_name_raises_error(self, syn): + """Test that creating without a name raises ValueError""" + # GIVEN a FormGroup without a name + form_group = FormGroup() + + # WHEN creating the form group + # THEN it should raise ValueError + with pytest.raises(ValueError, match="FormGroup 'name' must be provided"): + await form_group.create_async(synapse_client=syn) + + +class AsyncIteratorMock(MagicMock): + async def __aiter__(self): + # Simulate yielding items + yield 1 + yield 2 + yield 3 + + +class TestFormData: + """Unit tests for the FormData model.""" + + @pytest.fixture + def syn(self): + """Mock Synapse client""" + mock_syn = MagicMock(spec=Synapse) + return mock_syn + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_data_async""" + return { + "formDataId": "67890", + "groupId": "12345", + "name": "my_test_form_data", + "dataFileHandleId": "54321", + "createdOn": "2023-12-01T11:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T11:00:00.000Z", + "submissionStatus": { + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "submittedOn": "2023-12-01T11:05:00.000Z", + "reviewedBy": None, + "reviewedOn": None, + "rejectionReason": None, + }, + } + + async def test_create_async_success(self, syn, mock_response): + """Test successful form data creation""" + # GIVEN a FormData with required fields + form_data = FormData( + group_id="12345", + name="my_test_form_data", + data_file_handle_id="54321", + ) + + # WHEN creating the form data + with patch( + "synapseclient.api.create_form_data_async", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_create_form: + result = await form_data.create_async(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + form_change_request={ + "name": "my_test_form_data", + "fileHandleId": "54321", + }, + ) + + # AND the result should be a FormData with populated fields + assert isinstance(result, FormData) + assert result.name == "my_test_form_data" + assert result.form_data_id == "67890" + assert result.group_id == "12345" + assert result.data_file_handle_id == "54321" + assert result.created_by == "3350396" + assert ( + result.submission_status.state.value == "SUBMITTED_WAITING_FOR_REVIEW" + ) + + async def test_create_async_without_required_fields_raises_error(self, syn): + """Test that creating without required fields raises ValueError""" + # GIVEN a FormData missing required fields + form_data = FormData(name="incomplete_form_data") + + # WHEN creating the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, + match="'group_id', 'name', and 'data_file_handle_id' must be provided", + ): + await form_data.create_async(synapse_client=syn) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state", + [ + # Test for non-reviewers - allow all possible state filters + ( + False, + [ + StateEnum.WAITING_FOR_SUBMISSION, + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for reviewers - only allow review-related state filters + ( + True, + [ + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for non-reviewers - only allow selected state filters + (False, [StateEnum.ACCEPTED, StateEnum.REJECTED]), + # Test for reviewers - only allow selected state filters + (True, [StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.REJECTED]), + ], + ) + async def test_list_async(self, syn, as_reviewer, filter_by_state): + """Test listing form data asynchronously""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + mock_form_data_list = [ + { + "formDataId": "11111", + "groupId": "12345", + "name": "form_data_1", + "dataFileHandleId": "fh_1", + }, + { + "formDataId": "22222", + "groupId": "12345", + "name": "form_data_2", + "dataFileHandleId": "fh_2", + }, + { + "formDataId": "33333", + "groupId": "12345", + "name": "form_data_3", + "dataFileHandleId": "fh_3", + }, + ] + + async def mock_async_generator(): + for item in mock_form_data_list: + yield item + + # WHEN listing the form data + with patch( + "synapseclient.api.list_form_data_async", + return_value=mock_async_generator(), + ) as mock_list_form: + results = [] + + async for item in form_data.list_async( + synapse_client=syn, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ): + results.append(item) + + # THEN the results should be a list of FormData objects + assert len(results) == 3 + + assert all(isinstance(item, FormData) for item in results) + assert results[0].form_data_id == "11111" + assert results[1].form_data_id == "22222" + assert results[2].form_data_id == "33333" + + # THEN the API should be called with correct parameters + mock_list_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state, expected", + [ + # Test for non-reviewers - empty filter + (False, [], ValueError), + # Test for reviewers - empty filter + (True, [], ValueError), + # Test for reviewers - invalid state filter + (True, [StateEnum.WAITING_FOR_SUBMISSION], ValueError), + ], + ) + async def test_validate_filter_by_state_raises_error_for_invalid_states( + self, as_reviewer, filter_by_state, expected + ): + """Test that invalid state filters raise ValueError""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + # WHEN validating filter_by_state with invalid states for non-reviewer + # THEN it should raise ValueError + if expected is ValueError: + with pytest.raises(ValueError): + # Call the private method directly for testing + form_data._validate_filter_by_state( + filter_by_state=filter_by_state, + allow_waiting_submission=not as_reviewer, + ) From 5d9dabaeded9fc061f569d0a7dd86ff2cb37ea11 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 12:39:43 -0500 Subject: [PATCH 19/28] move test to async dir; also add test for download; remove syn fixture in test class --- .../unit_test_form_async.py} | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) rename tests/unit/synapseclient/models/{unit_test_form.py => async/unit_test_form_async.py} (81%) diff --git a/tests/unit/synapseclient/models/unit_test_form.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py similarity index 81% rename from tests/unit/synapseclient/models/unit_test_form.py rename to tests/unit/synapseclient/models/async/unit_test_form_async.py index baa52102c..23624f6fc 100644 --- a/tests/unit/synapseclient/models/unit_test_form.py +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -1,3 +1,4 @@ +import os from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -10,12 +11,6 @@ class TestFormGroup: """Unit tests for the FormGroup model.""" - @pytest.fixture - def syn(self): - """Mock Synapse client""" - mock_syn = MagicMock(spec=Synapse) - return mock_syn - @pytest.fixture def mock_response(self): """Mock API response from create_form_group_async""" @@ -64,23 +59,9 @@ async def test_create_async_without_name_raises_error(self, syn): await form_group.create_async(synapse_client=syn) -class AsyncIteratorMock(MagicMock): - async def __aiter__(self): - # Simulate yielding items - yield 1 - yield 2 - yield 3 - - class TestFormData: """Unit tests for the FormData model.""" - @pytest.fixture - def syn(self): - """Mock Synapse client""" - mock_syn = MagicMock(spec=Synapse) - return mock_syn - @pytest.fixture def mock_response(self): """Mock API response from create_form_data_async""" @@ -267,3 +248,48 @@ async def test_validate_filter_by_state_raises_error_for_invalid_states( filter_by_state=filter_by_state, allow_waiting_submission=not as_reviewer, ) + + async def test_download_async(self, syn): + """Test downloading form data asynchronously""" + # GIVEN a FormData with a form_data_id + form_data = FormData(form_data_id="67890", data_file_handle_id="54321") + + # WHEN downloading the form data + with patch( + "synapseclient.core.download.download_functions.download_by_file_handle", + new_callable=AsyncMock, + ) as mock_download_file_handle, patch.object(syn, "cache") as mock_cache, patch( + "synapseclient.core.download.download_functions.ensure_download_location_is_directory", + ) as mock_ensure_dir: + mock_cache.get.side_effect = "/tmp/foo" + mock_ensure_dir.return_value = ( + mock_cache.get_cache_dir.return_value + ) = "/tmp/download" + mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" + + result = await form_data.download_async( + synapse_client=syn, synapse_id="mock synapse_id" + ) + + # THEN the API should be called with correct parameters + mock_download_file_handle.assert_called_once_with( + file_handle_id=form_data.data_file_handle_id, + synapse_id="mock synapse_id", + entity_type="FileEntity", + destination=os.path.join(mock_ensure_dir.return_value, mock_file_name), + synapse_client=syn, + ) + + async def test_download_async_without_form_data_id_raises_error(self, syn): + """Test that downloading without form_data_id raises ValueError""" + # GIVEN a FormData without a form_data_id + form_data = FormData(form_data_id="67890") + + # WHEN downloading the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="data_file_handle_id must be set to download the file." + ): + await form_data.download_async( + synapse_client=syn, synapse_id="mock synapse_id" + ) From b189f110518df632c7222338c524f157ffabbdca Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 13:28:04 -0500 Subject: [PATCH 20/28] remove async in the function name in api --- synapseclient/api/__init__.py | 12 ++++-------- synapseclient/api/form_services.py | 8 ++++---- synapseclient/models/form.py | 12 ++++++------ .../models/async/unit_test_form_async.py | 13 ++++++------- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 6cf9c300c..8aa148628 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -87,11 +87,7 @@ put_file_multipart_add, put_file_multipart_complete, ) -from .form_services import ( - create_form_data_async, - create_form_group_async, - list_form_data_async, -) +from .form_services import create_form_data, create_form_group, list_form_data from .json_schema_services import ( bind_json_schema_to_entity, create_organization, @@ -288,7 +284,7 @@ "update_evaluation_acl", "get_evaluation_permissions", # form services - "create_form_group_async", - "create_form_data_async", - "list_form_data_async", + "create_form_group", + "create_form_data", + "list_form_data", ] diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index e00ae7648..0f0705b56 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -9,7 +9,7 @@ from synapseclient.models.mixins.form import StateEnum -async def create_form_group_async( +async def create_form_group( synapse_client: "Synapse", name: str, ) -> dict[str, Any]: @@ -32,7 +32,7 @@ async def create_form_group_async( return await client.rest_post_async(uri=f"/form/group?name={name}", body={}) -async def create_form_data_async( +async def create_form_data( synapse_client: "Synapse", group_id: str, form_change_request: dict[str, Any], @@ -62,7 +62,7 @@ async def create_form_data_async( ) -async def list_form_data_async( +async def list_form_data( synapse_client: "Synapse", group_id: str, filter_by_state: Optional[list["StateEnum"]] = None, @@ -164,7 +164,7 @@ def list_form_data_sync( Object matching """ return wrap_async_generator_to_sync_generator( - list_form_data_async( + list_form_data( synapse_client=synapse_client, group_id=group_id, filter_by_state=filter_by_state, diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 9b0211543..25770837c 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -56,9 +56,9 @@ async def create_my_form_group(): if not self.name: raise ValueError("FormGroup 'name' must be provided to create a FormGroup.") - from synapseclient.api.form_services import create_form_group_async + from synapseclient.api.form_services import create_form_group - response = await create_form_group_async( + response = await create_form_group( synapse_client=synapse_client, name=self.name, ) @@ -158,7 +158,7 @@ async def create_my_form_data(): ``` """ - from synapseclient.api import create_form_data_async + from synapseclient.api import create_form_data if not self.group_id or not self.name or not self.data_file_handle_id: raise ValueError( @@ -168,7 +168,7 @@ async def create_my_form_data(): form_change_request = FormChangeRequest( name=self.name, file_handle_id=self.data_file_handle_id ).to_dict() - response = await create_form_data_async( + response = await create_form_data( synapse_client=synapse_client, group_id=self.group_id, form_change_request=form_change_request, @@ -258,7 +258,7 @@ async def list_for_review(): asyncio.run(list_for_review()) ``` """ - from synapseclient.api import list_form_data_async + from synapseclient.api import list_form_data if not self.group_id: raise ValueError("'group_id' must be provided to list FormData.") @@ -274,7 +274,7 @@ async def list_for_review(): allow_waiting_submission=allow_waiting_submission, ) - gen = list_form_data_async( + gen = list_form_data( synapse_client=synapse_client, group_id=self.group_id, filter_by_state=filter_by_state, diff --git a/tests/unit/synapseclient/models/async/unit_test_form_async.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py index 23624f6fc..f3ca97670 100644 --- a/tests/unit/synapseclient/models/async/unit_test_form_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -1,9 +1,8 @@ import os -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest -from synapseclient import Synapse from synapseclient.models import FormData, FormGroup from synapseclient.models.mixins import StateEnum @@ -29,7 +28,7 @@ async def test_create_async_success(self, syn, mock_response): # WHEN creating the form group with patch( - "synapseclient.api.form_services.create_form_group_async", + "synapseclient.api.form_services.create_form_group", new_callable=AsyncMock, return_value=mock_response, ) as mock_create: @@ -64,7 +63,7 @@ class TestFormData: @pytest.fixture def mock_response(self): - """Mock API response from create_form_data_async""" + """Mock API response from create_form_data""" return { "formDataId": "67890", "groupId": "12345", @@ -93,7 +92,7 @@ async def test_create_async_success(self, syn, mock_response): # WHEN creating the form data with patch( - "synapseclient.api.create_form_data_async", + "synapseclient.api.create_form_data", new_callable=AsyncMock, return_value=mock_response, ) as mock_create_form: @@ -193,7 +192,7 @@ async def mock_async_generator(): # WHEN listing the form data with patch( - "synapseclient.api.list_form_data_async", + "synapseclient.api.list_form_data", return_value=mock_async_generator(), ) as mock_list_form: results = [] @@ -267,7 +266,7 @@ async def test_download_async(self, syn): ) = "/tmp/download" mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" - result = await form_data.download_async( + await form_data.download_async( synapse_client=syn, synapse_id="mock synapse_id" ) From 77365c9327a6b67ced8e7db9a73864b3b3b0c660 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 14:39:56 -0500 Subject: [PATCH 21/28] add sync version of the test --- synapseclient/api/__init__.py | 8 +- .../models/async/unit_test_form_async.py | 21 +- .../models/synchronous/unit_test_form.py | 287 ++++++++++++++++++ 3 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_form.py diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 8aa148628..86f1a9947 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -87,7 +87,12 @@ put_file_multipart_add, put_file_multipart_complete, ) -from .form_services import create_form_data, create_form_group, list_form_data +from .form_services import ( + create_form_data, + create_form_group, + list_form_data, + list_form_data_sync, +) from .json_schema_services import ( bind_json_schema_to_entity, create_organization, @@ -287,4 +292,5 @@ "create_form_group", "create_form_data", "list_form_data", + "list_form_data_sync", ] diff --git a/tests/unit/synapseclient/models/async/unit_test_form_async.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py index f3ca97670..fda6513c6 100644 --- a/tests/unit/synapseclient/models/async/unit_test_form_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -165,35 +165,34 @@ async def test_list_async(self, syn, as_reviewer, filter_by_state): # GIVEN a FormData with a group_id form_data = FormData(group_id="12345") - mock_form_data_list = [ - { + async def mock_form_data_list(): + yield { "formDataId": "11111", "groupId": "12345", "name": "form_data_1", "dataFileHandleId": "fh_1", - }, - { + } + yield { "formDataId": "22222", "groupId": "12345", "name": "form_data_2", "dataFileHandleId": "fh_2", - }, - { + } + yield { "formDataId": "33333", "groupId": "12345", "name": "form_data_3", "dataFileHandleId": "fh_3", - }, - ] + } - async def mock_async_generator(): - for item in mock_form_data_list: + async def mock_generator(): + async for item in mock_form_data_list(): yield item # WHEN listing the form data with patch( "synapseclient.api.list_form_data", - return_value=mock_async_generator(), + return_value=mock_generator(), ) as mock_list_form: results = [] diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_form.py b/tests/unit/synapseclient/models/synchronous/unit_test_form.py new file mode 100644 index 000000000..08a4a1845 --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_form.py @@ -0,0 +1,287 @@ +import os +from unittest.mock import patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models import FormData, FormGroup +from synapseclient.models.mixins import StateEnum + + +class TestFormGroup: + """Unit tests for the FormGroup model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_group""" + return { + "groupId": "12345", + "name": "my_test_form_group", + "createdOn": "2023-12-01T10:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T10:00:00.000Z", + } + + def test_create_success(self, syn, mock_response): + """Test successful form group creation""" + # GIVEN a FormGroup with a name + form_group = FormGroup(name="my_test_form_group") + + # WHEN creating the form group + with patch( + "synapseclient.api.form_services.create_form_group", + return_value=mock_response, + ) as mock_create: + result = form_group.create(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create.assert_called_once_with( + synapse_client=syn, + name="my_test_form_group", + ) + + # AND the result should be a FormGroup with populated fields + assert isinstance(result, FormGroup) + assert result.name == "my_test_form_group" + assert result.group_id == "12345" + assert result.created_by == "3350396" + assert result.created_on == "2023-12-01T10:00:00.000Z" + + def test_create_without_name_raises_error(self, syn): + """Test that creating without a name raises ValueError""" + # GIVEN a FormGroup without a name + form_group = FormGroup() + + # WHEN creating the form group + # THEN it should raise ValueError + with pytest.raises(ValueError, match="FormGroup 'name' must be provided"): + form_group.create(synapse_client=syn) + + +class TestFormData: + """Unit tests for the FormData model.""" + + @pytest.fixture + def mock_response(self): + """Mock API response from create_form_data""" + return { + "formDataId": "67890", + "groupId": "12345", + "name": "my_test_form_data", + "dataFileHandleId": "54321", + "createdOn": "2023-12-01T11:00:00.000Z", + "createdBy": "3350396", + "modifiedOn": "2023-12-01T11:00:00.000Z", + "submissionStatus": { + "state": "SUBMITTED_WAITING_FOR_REVIEW", + "submittedOn": "2023-12-01T11:05:00.000Z", + "reviewedBy": None, + "reviewedOn": None, + "rejectionReason": None, + }, + } + + def test_create_success(self, syn, mock_response): + """Test successful form data creation""" + # GIVEN a FormData with required fields + form_data = FormData( + group_id="12345", + name="my_test_form_data", + data_file_handle_id="54321", + ) + + # WHEN creating the form data + with patch( + "synapseclient.api.create_form_data", + return_value=mock_response, + ) as mock_create_form: + result = form_data.create(synapse_client=syn) + + # THEN the API should be called with correct parameters + mock_create_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + form_change_request={ + "name": "my_test_form_data", + "fileHandleId": "54321", + }, + ) + + # AND the result should be a FormData with populated fields + assert isinstance(result, FormData) + assert result.name == "my_test_form_data" + assert result.form_data_id == "67890" + assert result.group_id == "12345" + assert result.data_file_handle_id == "54321" + assert result.created_by == "3350396" + assert ( + result.submission_status.state.value == "SUBMITTED_WAITING_FOR_REVIEW" + ) + + def test_create_without_required_fields_raises_error(self, syn): + """Test that creating without required fields raises ValueError""" + # GIVEN a FormData missing required fields + form_data = FormData(name="incomplete_form_data") + + # WHEN creating the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, + match="'group_id', 'name', and 'data_file_handle_id' must be provided", + ): + form_data.create(synapse_client=syn) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state", + [ + # Test for non-reviewers - allow all possible state filters + ( + False, + [ + StateEnum.WAITING_FOR_SUBMISSION, + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for reviewers - only allow review-related state filters + ( + True, + [ + StateEnum.SUBMITTED_WAITING_FOR_REVIEW, + StateEnum.ACCEPTED, + StateEnum.REJECTED, + ], + ), + # Test for non-reviewers - only allow selected state filters + (False, [StateEnum.ACCEPTED, StateEnum.REJECTED]), + # Test for reviewers - only allow selected state filters + (True, [StateEnum.SUBMITTED_WAITING_FOR_REVIEW, StateEnum.REJECTED]), + ], + ) + def test_list(self, syn, as_reviewer, filter_by_state): + """Test listing form data""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + async def mock_form_data_list(): + yield { + "formDataId": "11111", + "groupId": "12345", + "name": "form_data_1", + "dataFileHandleId": "fh_1", + } + yield { + "formDataId": "22222", + "groupId": "12345", + "name": "form_data_2", + "dataFileHandleId": "fh_2", + } + yield { + "formDataId": "33333", + "groupId": "12345", + "name": "form_data_3", + "dataFileHandleId": "fh_3", + } + + async def mock_generator(): + async for item in mock_form_data_list(): + yield item + + # WHEN listing the form data + with patch( + "synapseclient.api.list_form_data", + return_value=mock_generator(), + ) as mock_list_form: + results = [] + + for item in form_data.list( + synapse_client=syn, + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ): + results.append(item) + + # THEN the results should be a list of FormData objects + assert len(results) == 3 + + assert all(isinstance(item, FormData) for item in results) + assert results[0].form_data_id == "11111" + assert results[1].form_data_id == "22222" + assert results[2].form_data_id == "33333" + + # THEN the API should be called with correct parameters + mock_list_form.assert_called_once_with( + synapse_client=syn, + group_id="12345", + filter_by_state=filter_by_state, + as_reviewer=as_reviewer, + ) + + @pytest.mark.parametrize( + "as_reviewer,filter_by_state, expected", + [ + # Test for non-reviewers - empty filter + (False, [], ValueError), + # Test for reviewers - empty filter + (True, [], ValueError), + # Test for reviewers - invalid state filter + (True, [StateEnum.WAITING_FOR_SUBMISSION], ValueError), + ], + ) + def test_validate_filter_by_state_raises_error_for_invalid_states( + self, as_reviewer, filter_by_state, expected + ): + """Test that invalid state filters raise ValueError""" + # GIVEN a FormData with a group_id + form_data = FormData(group_id="12345") + + # WHEN validating filter_by_state with invalid states for non-reviewer + # THEN it should raise ValueError + if expected is ValueError: + with pytest.raises(ValueError): + # Call the private method directly for testing + form_data._validate_filter_by_state( + filter_by_state=filter_by_state, + allow_waiting_submission=not as_reviewer, + ) + + def test_download(self, syn): + """Test downloading form data""" + # GIVEN a FormData with a form_data_id + form_data = FormData(form_data_id="67890", data_file_handle_id="54321") + + # WHEN downloading the form data + with patch( + "synapseclient.core.download.download_functions.download_by_file_handle", + ) as mock_download_file_handle, patch.object(syn, "cache") as mock_cache, patch( + "synapseclient.core.download.download_functions.ensure_download_location_is_directory", + ) as mock_ensure_dir: + mock_cache.get.side_effect = "/tmp/foo" + mock_ensure_dir.return_value = ( + mock_cache.get_cache_dir.return_value + ) = "/tmp/download" + mock_file_name = f"SYNAPSE_FORM_{form_data.data_file_handle_id}.csv" + + form_data.download(synapse_client=syn, synapse_id="mock synapse_id") + + # THEN the API should be called with correct parameters + mock_download_file_handle.assert_called_once_with( + file_handle_id=form_data.data_file_handle_id, + synapse_id="mock synapse_id", + entity_type="FileEntity", + destination=os.path.join(mock_ensure_dir.return_value, mock_file_name), + synapse_client=syn, + ) + + def test_download_without_form_data_id_raises_error(self, syn): + """Test that downloading without form_data_id raises ValueError""" + # GIVEN a FormData without a form_data_id + form_data = FormData(form_data_id="67890") + + # WHEN downloading the form data + # THEN it should raise ValueError + with pytest.raises( + ValueError, match="data_file_handle_id must be set to download the file." + ): + form_data.download(synapse_client=syn, synapse_id="mock synapse_id") From 9eb3af181fa4ea87a76022b4fde7c1c3d34c9973 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 16:18:59 -0500 Subject: [PATCH 22/28] add integration test async --- .../models/async/test_form_async.py | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 tests/integration/synapseclient/models/async/test_form_async.py diff --git a/tests/integration/synapseclient/models/async/test_form_async.py b/tests/integration/synapseclient/models/async/test_form_async.py new file mode 100644 index 000000000..10c678348 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_form_async.py @@ -0,0 +1,233 @@ +""" +Integration tests for the synapseclient.models.Form class. +""" +import tempfile +import uuid +from typing import Callable + +import pytest + +import synapseclient.core.utils as utils +from synapseclient import Synapse +from synapseclient.models import File, FormData, FormGroup, Project +from synapseclient.models.mixins.form import StateEnum + + +class TestFormGroup: + async def test_create_form_group( + self, syn, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test creating a form group.""" + unique_name = str(uuid.uuid4()) + form_group = await FormGroup(name=unique_name).create_async(synapse_client=syn) + + assert form_group is not None + assert form_group.group_id is not None + assert form_group.name == unique_name + + schedule_for_cleanup(form_group.group_id) + + async def test_raise_error_on_missing_name(self, syn) -> None: + """Test that creating a form group without a name raises an error.""" + form_group = FormGroup() + + with pytest.raises(ValueError) as e: + await form_group.create_async(synapse_client=syn) + assert ( + str(e.value) == "FormGroup 'name' must be provided to create a FormGroup." + ) + + +class TestFormData: + @pytest.fixture(autouse=True, scope="session") + async def test_form_group( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> str: + """Create a test form group for use in form data tests.""" + unique_name = "test_form_group_" + str(uuid.uuid4()) + form_group = FormGroup(name=unique_name) + form_group = await form_group.create_async(synapse_client=syn) + + schedule_for_cleanup(form_group.group_id) + + return form_group + + @pytest.fixture(autouse=True, scope="session") + async def test_file( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> str: + """Create a test file for use in form data tests.""" + # Create a test project and a test file to get a file handle ID + project_name = str(uuid.uuid4()) + project = Project(name=project_name) + project = await project.store_async(synapse_client=syn) + + file_path = utils.make_bogus_data_file() + file = await File(path=file_path, parent_id=project.id).store_async( + synapse_client=syn + ) + + schedule_for_cleanup(file.id) + schedule_for_cleanup(file_path) + schedule_for_cleanup(project.id) + + return file + + async def test_create_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test creating form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + + assert form_data is not None + assert form_data.form_data_id is not None + assert form_data.name == unique_name + assert form_data.group_id == test_form_group.group_id + assert form_data.data_file_handle_id == test_file.file_handle.id + assert form_data.submission_status.state.value == "WAITING_FOR_SUBMISSION" + + schedule_for_cleanup(form_data.form_data_id) + + async def test_create_raise_error_on_missing_fields(self, syn: Synapse) -> None: + """Test that creating form data without required fields raises an error.""" + form_data = FormData() + + with pytest.raises(ValueError) as e: + await form_data.create_async(synapse_client=syn) + assert ( + str(e.value) + == "'group_id', 'name', and 'data_file_handle_id' must be provided to create a FormData." + ) + + async def test_list_form_data_reviewer_false( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data owned by the caller + retrieved_ids = [] + async for form_data in FormData(group_id=test_form_group.group_id).list_async( + synapse_client=syn, + filter_by_state=[StateEnum.WAITING_FOR_SUBMISSION], + as_reviewer=False, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + async def test_list_form_data_reviewer_true( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data as reviewer.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + # Submit the form data + syn.restPOST(uri=f"/form/data/{form_data.form_data_id}/submit", body={}) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data as reviewer + retrieved_ids = [] + async for form_data in FormData(group_id=test_form_group.group_id).list_async( + synapse_client=syn, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW], + as_reviewer=True, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + async def test_download_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + + schedule_for_cleanup(form_data.form_data_id) + + downloaded_form_path = await FormData( + data_file_handle_id=test_file.file_handle.id + ).download_async(synapse_client=syn, synapse_id=form_data.form_data_id) + + schedule_for_cleanup(downloaded_form_path) + + assert test_file.file_handle.id in downloaded_form_path + + async def test_download_form_data_with_directory( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data to a specific directory.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = await FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create_async(synapse_client=syn) + tmp_dir = tempfile.mkdtemp() + schedule_for_cleanup(tmp_dir) + + downloaded_form_path = await FormData( + data_file_handle_id=test_file.file_handle.id + ).download_async( + synapse_client=syn, + synapse_id=form_data.form_data_id, + download_location=tmp_dir, + ) + + schedule_for_cleanup(form_data.form_data_id) + + assert test_file.file_handle.id in downloaded_form_path + assert str(downloaded_form_path).startswith(str(tmp_dir)) From c2be473e87a46a9ffca86eaf538719e6bd2bf112 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 16:53:21 -0500 Subject: [PATCH 23/28] add test for sync --- .../models/synchronous/test_form.py | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 tests/integration/synapseclient/models/synchronous/test_form.py diff --git a/tests/integration/synapseclient/models/synchronous/test_form.py b/tests/integration/synapseclient/models/synchronous/test_form.py new file mode 100644 index 000000000..6a01ff4d8 --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_form.py @@ -0,0 +1,229 @@ +""" +Integration tests for the synapseclient.models.Form class. +""" +import tempfile +import uuid +from typing import Callable + +import pytest + +import synapseclient.core.utils as utils +from synapseclient import Synapse +from synapseclient.models import File, FormData, FormGroup, Project +from synapseclient.models.mixins.form import StateEnum + + +class TestFormGroup: + def test_create_form_group( + self, syn, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test creating a form group.""" + unique_name = str(uuid.uuid4()) + form_group = FormGroup(name=unique_name).create(synapse_client=syn) + + assert form_group is not None + assert form_group.group_id is not None + assert form_group.name == unique_name + + schedule_for_cleanup(form_group.group_id) + + def test_raise_error_on_missing_name(self, syn) -> None: + """Test that creating a form group without a name raises an error.""" + form_group = FormGroup() + + with pytest.raises(ValueError) as e: + form_group.create(synapse_client=syn) + assert ( + str(e.value) == "FormGroup 'name' must be provided to create a FormGroup." + ) + + +class TestFormData: + @pytest.fixture(autouse=True, scope="session") + def test_form_group( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> str: + """Create a test form group for use in form data tests.""" + unique_name = "test_form_group_" + str(uuid.uuid4()) + form_group = FormGroup(name=unique_name) + form_group = form_group.create(synapse_client=syn) + + schedule_for_cleanup(form_group.group_id) + + return form_group + + @pytest.fixture(autouse=True, scope="session") + def test_file(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> str: + """Create a test file for use in form data tests.""" + # Create a test project and a test file to get a file handle ID + project_name = str(uuid.uuid4()) + project = Project(name=project_name) + project = project.store(synapse_client=syn) + + file_path = utils.make_bogus_data_file() + file = File(path=file_path, parent_id=project.id).store(synapse_client=syn) + + schedule_for_cleanup(file.id) + schedule_for_cleanup(file_path) + schedule_for_cleanup(project.id) + + return file + + def test_create_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test creating form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + + assert form_data is not None + assert form_data.form_data_id is not None + assert form_data.name == unique_name + assert form_data.group_id == test_form_group.group_id + assert form_data.data_file_handle_id == test_file.file_handle.id + assert form_data.submission_status.state.value == "WAITING_FOR_SUBMISSION" + + schedule_for_cleanup(form_data.form_data_id) + + def test_create_raise_error_on_missing_fields(self, syn: Synapse) -> None: + """Test that creating form data without required fields raises an error.""" + form_data = FormData() + + with pytest.raises(ValueError) as e: + form_data.create(synapse_client=syn) + assert ( + str(e.value) + == "'group_id', 'name', and 'data_file_handle_id' must be provided to create a FormData." + ) + + def test_list_form_data_reviewer_false( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data owned by the caller + retrieved_ids = [] + for form_data in FormData(group_id=test_form_group.group_id).list( + synapse_client=syn, + filter_by_state=[StateEnum.WAITING_FOR_SUBMISSION], + as_reviewer=False, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + def test_list_form_data_reviewer_true( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test listing form data as reviewer.""" + # Create multiple form data entries + form_data_ids = [] + for i in range(3): + unique_name = f"test_form_data_{i}_" + str(uuid.uuid4()) + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + # Submit the form data + syn.restPOST(uri=f"/form/data/{form_data.form_data_id}/submit", body={}) + form_data_ids.append(form_data.form_data_id) + schedule_for_cleanup(form_data.form_data_id) + + # List form data as reviewer + retrieved_ids = [] + for form_data in FormData(group_id=test_form_group.group_id).list( + synapse_client=syn, + filter_by_state=[StateEnum.SUBMITTED_WAITING_FOR_REVIEW], + as_reviewer=True, + ): + retrieved_ids.append(form_data.form_data_id) + + for form_data_id in form_data_ids: + assert form_data_id in retrieved_ids + + def test_download_form_data( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + + schedule_for_cleanup(form_data.form_data_id) + + downloaded_form_path = FormData( + data_file_handle_id=test_file.file_handle.id + ).download(synapse_client=syn, synapse_id=form_data.form_data_id) + + schedule_for_cleanup(downloaded_form_path) + + assert test_file.file_handle.id in downloaded_form_path + + def test_download_form_data_with_directory( + self, + syn: Synapse, + test_form_group: FormGroup, + test_file: File, + schedule_for_cleanup: Callable[..., None], + ) -> None: + """Test downloading form data to a specific directory.""" + unique_name = "test_form_data_" + str(uuid.uuid4()) + + form_data = FormData( + name=unique_name, + group_id=test_form_group.group_id, + data_file_handle_id=test_file.file_handle.id, + ).create(synapse_client=syn) + tmp_dir = tempfile.mkdtemp() + schedule_for_cleanup(tmp_dir) + + downloaded_form_path = FormData( + data_file_handle_id=test_file.file_handle.id + ).download( + synapse_client=syn, + synapse_id=form_data.form_data_id, + download_location=tmp_dir, + ) + + schedule_for_cleanup(form_data.form_data_id) + + assert test_file.file_handle.id in downloaded_form_path + assert str(downloaded_form_path).startswith(str(tmp_dir)) From a6c09d8bf0a8a7ed8176d5bcedc383c54fafe3f4 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 8 Dec 2025 16:59:33 -0500 Subject: [PATCH 24/28] rename test --- .../mixins/{unit_test_form.py => unit_test_form_mixin.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/synapseclient/mixins/{unit_test_form.py => unit_test_form_mixin.py} (100%) diff --git a/tests/unit/synapseclient/mixins/unit_test_form.py b/tests/unit/synapseclient/mixins/unit_test_form_mixin.py similarity index 100% rename from tests/unit/synapseclient/mixins/unit_test_form.py rename to tests/unit/synapseclient/mixins/unit_test_form_mixin.py From cab8a3f7b0380fbc0a4dad494ceefce3aa046084 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Tue, 9 Dec 2025 10:38:09 -0500 Subject: [PATCH 25/28] update comment in the code --- synapseclient/models/protocols/form_protocol.py | 6 ++++-- .../unit/synapseclient/models/async/unit_test_form_async.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py index 02ddd2af9..185b7a664 100644 --- a/synapseclient/models/protocols/form_protocol.py +++ b/synapseclient/models/protocols/form_protocol.py @@ -23,7 +23,6 @@ def create( Create a FormGroup with the provided name. This method is idempotent. If a group with the provided name already exists and the caller has ACCESS_TYPE.READ permission the existing FormGroup will be returned. Arguments: - name: A globally unique name for the group. Required. Between 3 and 256 characters. synapse_client: Optional Synapse client instance for authentication. Returns: @@ -63,6 +62,9 @@ def create( Returns: A FormData object containing the details of the created form data. + Note: + The `name` attribute must be set on the FormGroup instance before calling `create()`. + Examples: create a form data ```python @@ -187,8 +189,8 @@ def download( Download the data file associated with this FormData object. Arguments: - download_location: The directory where the file should be downloaded. synapse_id: The Synapse ID of the entity that owns the file handle (e.g., "syn12345678"). + download_location: The directory where the file should be downloaded. synapse_client: The Synapse client to use for the request. Returns: diff --git a/tests/unit/synapseclient/models/async/unit_test_form_async.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py index fda6513c6..52bc494b5 100644 --- a/tests/unit/synapseclient/models/async/unit_test_form_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -278,9 +278,9 @@ async def test_download_async(self, syn): synapse_client=syn, ) - async def test_download_async_without_form_data_id_raises_error(self, syn): - """Test that downloading without form_data_id raises ValueError""" - # GIVEN a FormData without a form_data_id + async def test_download_async_without_data_file_id_raises_error(self, syn): + """Test that downloading without data_file_handle_id raises ValueError""" + # GIVEN a FormData without data_file_handle_id form_data = FormData(form_data_id="67890") # WHEN downloading the form data From c90891bde8b224332c1ffd0102a4fec6e63fb9c6 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Tue, 9 Dec 2025 11:51:25 -0500 Subject: [PATCH 26/28] fix docstring and also docstring example --- synapseclient/api/form_services.py | 4 ++-- synapseclient/models/form.py | 14 +++++++++----- synapseclient/models/protocols/form_protocol.py | 14 +++++++------- .../synapseclient/models/async/test_form_async.py | 4 ++-- .../synapseclient/models/synchronous/test_form.py | 6 ++++-- .../models/async/unit_test_form_async.py | 2 +- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/synapseclient/api/form_services.py b/synapseclient/api/form_services.py index 0f0705b56..c410da9db 100644 --- a/synapseclient/api/form_services.py +++ b/synapseclient/api/form_services.py @@ -87,7 +87,7 @@ async def list_form_data( - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED - If None, returns all FormData objects. + Note: filter_by_state cannot be None When as_reviewer=True, valid values are: - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) @@ -148,7 +148,7 @@ def list_form_data_sync( - StateEnum.SUBMITTED_WAITING_FOR_REVIEW - StateEnum.ACCEPTED - StateEnum.REJECTED - If None, returns all FormData objects. + Note: filter_by_state cannot be None or empty When as_reviewer=True, valid values are: - StateEnum.SUBMITTED_WAITING_FOR_REVIEW (default if None) diff --git a/synapseclient/models/form.py b/synapseclient/models/form.py index 25770837c..10e714148 100644 --- a/synapseclient/models/form.py +++ b/synapseclient/models/form.py @@ -385,15 +385,19 @@ async def download_async( Examples: Download form data file ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import File, FormData + async def download_form_data(): syn = Synapse() syn.login() - form_data = await FormData(form_data_id="123").get_async() - path = await form_data.download_async( - synapse_id="syn12345678", - download_location="/tmp" - ) + file = await File(id="syn123", download_file=True).get_async() + file_handle_id = file.file_handle.id + + path = await FormData(data_file_handle_id=file_handle_id).download_async(synapse_id="syn123") + print(f"Downloaded to: {path}") diff --git a/synapseclient/models/protocols/form_protocol.py b/synapseclient/models/protocols/form_protocol.py index 185b7a664..7e2b5a0e9 100644 --- a/synapseclient/models/protocols/form_protocol.py +++ b/synapseclient/models/protocols/form_protocol.py @@ -63,7 +63,7 @@ def create( A FormData object containing the details of the created form data. Note: - The `name` attribute must be set on the FormGroup instance before calling `create()`. + The `name` attribute must be set on the FormData instance before calling `create()`. Examples: create a form data @@ -200,16 +200,16 @@ def download( ```python from synapseclient import Synapse - from synapseclient.models import FormData + from synapseclient.models import File, FormData syn = Synapse() syn.login() - form_data = FormData(form_data_id="123").get() # First get the FormData - path = form_data.download( # Then download - synapse_id="syn12345678", - download_location="/tmp" - ) + file = File(id="syn123", download_file=True).get() + file_handle_id = file.file_handle.id + + path = FormData(data_file_handle_id=file_handle_id).download(synapse_id="syn123") + print(f"Downloaded to: {path}") ``` """ diff --git a/tests/integration/synapseclient/models/async/test_form_async.py b/tests/integration/synapseclient/models/async/test_form_async.py index 10c678348..6253b22e5 100644 --- a/tests/integration/synapseclient/models/async/test_form_async.py +++ b/tests/integration/synapseclient/models/async/test_form_async.py @@ -42,7 +42,7 @@ class TestFormData: @pytest.fixture(autouse=True, scope="session") async def test_form_group( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> str: + ) -> FormGroup: """Create a test form group for use in form data tests.""" unique_name = "test_form_group_" + str(uuid.uuid4()) form_group = FormGroup(name=unique_name) @@ -55,7 +55,7 @@ async def test_form_group( @pytest.fixture(autouse=True, scope="session") async def test_file( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> str: + ) -> File: """Create a test file for use in form data tests.""" # Create a test project and a test file to get a file handle ID project_name = str(uuid.uuid4()) diff --git a/tests/integration/synapseclient/models/synchronous/test_form.py b/tests/integration/synapseclient/models/synchronous/test_form.py index 6a01ff4d8..96f95e5c3 100644 --- a/tests/integration/synapseclient/models/synchronous/test_form.py +++ b/tests/integration/synapseclient/models/synchronous/test_form.py @@ -42,7 +42,7 @@ class TestFormData: @pytest.fixture(autouse=True, scope="session") def test_form_group( self, syn: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> str: + ) -> FormGroup: """Create a test form group for use in form data tests.""" unique_name = "test_form_group_" + str(uuid.uuid4()) form_group = FormGroup(name=unique_name) @@ -53,7 +53,9 @@ def test_form_group( return form_group @pytest.fixture(autouse=True, scope="session") - def test_file(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> str: + def test_file( + self, syn: Synapse, schedule_for_cleanup: Callable[..., None] + ) -> File: """Create a test file for use in form data tests.""" # Create a test project and a test file to get a file handle ID project_name = str(uuid.uuid4()) diff --git a/tests/unit/synapseclient/models/async/unit_test_form_async.py b/tests/unit/synapseclient/models/async/unit_test_form_async.py index 52bc494b5..7ea25df8f 100644 --- a/tests/unit/synapseclient/models/async/unit_test_form_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_form_async.py @@ -278,7 +278,7 @@ async def test_download_async(self, syn): synapse_client=syn, ) - async def test_download_async_without_data_file_id_raises_error(self, syn): + async def test_download_async_without_data_file_handle_id_raises_error(self, syn): """Test that downloading without data_file_handle_id raises ValueError""" # GIVEN a FormData without data_file_handle_id form_data = FormData(form_data_id="67890") From 913e910a36570b12e391082300e595e0044968c0 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Tue, 9 Dec 2025 11:57:16 -0500 Subject: [PATCH 27/28] fix test name --- .../unit/synapseclient/models/synchronous/unit_test_form.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_form.py b/tests/unit/synapseclient/models/synchronous/unit_test_form.py index 08a4a1845..b3ef389ae 100644 --- a/tests/unit/synapseclient/models/synchronous/unit_test_form.py +++ b/tests/unit/synapseclient/models/synchronous/unit_test_form.py @@ -274,9 +274,9 @@ def test_download(self, syn): synapse_client=syn, ) - def test_download_without_form_data_id_raises_error(self, syn): - """Test that downloading without form_data_id raises ValueError""" - # GIVEN a FormData without a form_data_id + def test_download_without_data_file_handle_id_raises_error(self, syn): + """Test that downloading without data_file_handle_id raises ValueError""" + # GIVEN a FormData without a data_file_handle_id form_data = FormData(form_data_id="67890") # WHEN downloading the form data From 39d0a5fa2d2deb2f795edcc7950797fcc5e5decc Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Tue, 9 Dec 2025 11:58:24 -0500 Subject: [PATCH 28/28] remove unused import --- tests/unit/synapseclient/models/synchronous/unit_test_form.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_form.py b/tests/unit/synapseclient/models/synchronous/unit_test_form.py index b3ef389ae..7140d743a 100644 --- a/tests/unit/synapseclient/models/synchronous/unit_test_form.py +++ b/tests/unit/synapseclient/models/synchronous/unit_test_form.py @@ -3,7 +3,6 @@ import pytest -from synapseclient import Synapse from synapseclient.models import FormData, FormGroup from synapseclient.models.mixins import StateEnum