diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1371780e5..1888bfa63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,7 @@ jobs: needs: [pre-commit] strategy: + fail-fast: false matrix: os: [ubuntu-22.04, macos-13, windows-2022] diff --git a/.gitignore b/.gitignore index c798f50b0..247cd7d73 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ coverage.xml .ipynb_checkpoints *.ipynb .env +test.synapseConfig diff --git a/docs/reference/experimental/async/factory_operations.md b/docs/reference/experimental/async/factory_operations.md new file mode 100644 index 000000000..5e498c1fb --- /dev/null +++ b/docs/reference/experimental/async/factory_operations.md @@ -0,0 +1,30 @@ +# Synapse Factory Operations + +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 + +[](){ #factory-get-async } +::: synapseclient.operations.get_async + +[](){ #factory-file-options-async } +::: synapseclient.operations.FileOptions + options: + inherited_members: true + +[](){ #factory-activity-options-async } +::: synapseclient.operations.ActivityOptions + options: + inherited_members: true + +[](){ #factory-table-options-async } +::: synapseclient.operations.TableOptions + options: + inherited_members: true + +[](){ #factory-link-options-async } +::: synapseclient.operations.LinkOptions + options: + inherited_members: true diff --git a/docs/reference/experimental/async/link_entity.md b/docs/reference/experimental/async/link_entity.md new file mode 100644 index 000000000..2b81f6b64 --- /dev/null +++ b/docs/reference/experimental/async/link_entity.md @@ -0,0 +1,16 @@ +[](){ #link-async } +# Link + +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.Link + options: + inherited_members: true + members: + - get_async + - store_async +--- diff --git a/docs/reference/experimental/sync/factory_operations.md b/docs/reference/experimental/sync/factory_operations.md new file mode 100644 index 000000000..3a3bdb90c --- /dev/null +++ b/docs/reference/experimental/sync/factory_operations.md @@ -0,0 +1,30 @@ +# Synapse Factory Operations + +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 + +[](){ #factory-get-sync } +::: synapseclient.operations.get + +[](){ #factory-file-options-sync } +::: synapseclient.operations.FileOptions + options: + inherited_members: true + +[](){ #factory-activity-options-sync } +::: synapseclient.operations.ActivityOptions + options: + inherited_members: true + +[](){ #factory-table-options-sync } +::: synapseclient.operations.TableOptions + options: + inherited_members: true + +[](){ #factory-link-options-sync } +::: synapseclient.operations.LinkOptions + options: + inherited_members: true diff --git a/docs/reference/experimental/sync/link_entity.md b/docs/reference/experimental/sync/link_entity.md new file mode 100644 index 000000000..e5ee18793 --- /dev/null +++ b/docs/reference/experimental/sync/link_entity.md @@ -0,0 +1,16 @@ +[](){ #link-sync } +# Link + +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.Link + options: + inherited_members: true + members: + - get + - store +--- diff --git a/mkdocs.yml b/mkdocs.yml index 5d9ba6fac..8ecb027ab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,7 @@ nav: - Core: reference/core.md - REST Apis: reference/rest_apis.md - Experimental: + - Factory Operations: reference/experimental/sync/factory_operations.md - Agent: reference/experimental/sync/agent.md - Project: reference/experimental/sync/project.md - Folder: reference/experimental/sync/folder.md @@ -94,8 +95,10 @@ nav: - Activity: reference/experimental/sync/activity.md - Team: reference/experimental/sync/team.md - UserProfile: reference/experimental/sync/user_profile.md + - Link: reference/experimental/sync/link_entity.md - Functional Interfaces: reference/experimental/functional_interfaces.md - Asynchronous: + - Factory Operations: reference/experimental/async/factory_operations.md - Agent: reference/experimental/async/agent.md - Project: reference/experimental/async/project.md - Folder: reference/experimental/async/folder.md @@ -110,6 +113,7 @@ nav: - Activity: reference/experimental/async/activity.md - Team: reference/experimental/async/team.md - UserProfile: reference/experimental/async/user_profile.md + - Link: reference/experimental/async/link_entity.md - Mixins: - AccessControllable: reference/experimental/mixins/access_controllable.md - StorableContainer: reference/experimental/mixins/storable_container.md diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 7a3b2d3df..4abbfeccb 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -31,6 +31,7 @@ delete_entity_generated_by, delete_entity_provenance, get_activity, + get_child, get_children, get_entities_by_md5, get_entity, @@ -167,6 +168,7 @@ "get_activity", "create_activity", "update_activity", + "get_child", "get_children", "post_entity_acl", "put_entity_acl", diff --git a/synapseclient/api/entity_factory.py b/synapseclient/api/entity_factory.py index 020ff2710..6eb71e278 100644 --- a/synapseclient/api/entity_factory.py +++ b/synapseclient/api/entity_factory.py @@ -340,6 +340,7 @@ class type. This will also download the file if `download_file` is set to True. EntityView, File, Folder, + Link, MaterializedView, Project, SubmissionView, @@ -377,6 +378,7 @@ class type. This will also download the file if `download_file` is set to True. concrete_types.MATERIALIZED_VIEW: MaterializedView, concrete_types.SUBMISSION_VIEW: SubmissionView, concrete_types.VIRTUAL_TABLE: VirtualTable, + concrete_types.LINK_ENTITY: Link, } entity_class = ENTITY_TYPE_MAP.get(entity["concreteType"], None) diff --git a/synapseclient/api/entity_services.py b/synapseclient/api/entity_services.py index 1d512d657..759be644a 100644 --- a/synapseclient/api/entity_services.py +++ b/synapseclient/api/entity_services.py @@ -1264,6 +1264,99 @@ async def main(): yield child +async def get_child( + entity_name: str, + parent_id: Optional[str] = None, + *, + synapse_client: Optional["Synapse"] = None, +) -> Optional[str]: + """ + Retrieve an entityId for a given parent ID and entity name. + + This service can also be used to lookup projectId by setting the parentId to None. + + This calls to the REST API found here: + + Arguments: + entity_name: The name of the entity to find + parent_id: The parent ID. Set to None when looking up a project by name. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The entity ID if found, None if not found. + + Raises: + SynapseHTTPError: If there's an error other than "not found" (404). + + Example: Getting a child entity ID + Find a file by name within a folder: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.api import get_child + + syn = Synapse() + syn.login() + + async def main(): + entity_id = await get_child( + entity_name="my_file.txt", + parent_id="syn123456" + ) + if entity_id: + print(f"Found entity: {entity_id}") + else: + print("Entity not found") + + asyncio.run(main()) + ``` + + Example: Getting a project by name + Find a project by name: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.api import get_child + + syn = Synapse() + syn.login() + + async def main(): + project_id = await get_child( + entity_name="My Project", + parent_id=None # None for projects + ) + if project_id: + print(f"Found project: {project_id}") + + asyncio.run(main()) + ``` + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + + entity_lookup_request = { + "parentId": parent_id, + "entityName": entity_name, + } + + try: + response = await client.rest_post_async( + uri="/entity/child", body=json.dumps(entity_lookup_request) + ) + return response.get("id") + except SynapseHTTPError as e: + if e.response.status_code == 404: + # Entity not found + return None + raise + + async def set_entity_permissions( entity_id: str, principal_id: Optional[str] = None, diff --git a/synapseclient/client.py b/synapseclient/client.py index a14cc72db..bdf9b7dc5 100644 --- a/synapseclient/client.py +++ b/synapseclient/client.py @@ -2042,8 +2042,7 @@ def _getWithEntityBundle( if_collision=ifcollision, submission=submission, synapse_client=self, - ), - syn=self, + ) ) else: # no filehandle means that we do not have DOWNLOAD permission warning_message = ( @@ -2452,8 +2451,7 @@ def store( md5=local_file_md5_hex or local_state_fh.get("contentMd5"), file_size=local_state_fh.get("contentSize"), mimetype=local_state_fh.get("contentType"), - ), - self, + ) ) properties["dataFileHandleId"] = fileHandle["id"] local_state["_file_handle"] = fileHandle @@ -2911,8 +2909,7 @@ async def upload_file(): return wrap_async_to_sync( upload_file_handle_async( self, parent, path, synapseStore, md5, file_size, mimetype - ), - self, + ) ) ############################################################ @@ -6797,8 +6794,7 @@ def getWiki(self, owner, subpageId=None, version=None): cache_dir, str(wiki.markdownFileHandleId) + ".md" ), synapse_client=self, - ), - syn=self, + ) ) try: import gzip @@ -6849,9 +6845,7 @@ def _storeWiki(self, wiki: Wiki, createOrUpdate: bool) -> Wiki: # Convert all attachments into file handles if wiki.get("attachments") is not None: for attachment in wiki["attachments"]: - fileHandle = wrap_async_to_sync( - upload_synapse_s3(self, attachment), self - ) + fileHandle = wrap_async_to_sync(upload_synapse_s3(self, attachment)) wiki["attachmentFileHandleIds"].append(fileHandle["id"]) del wiki["attachments"] @@ -7800,7 +7794,7 @@ async def upload_csv_with_chunk_method(table_id, path): """ fileHandleId = wrap_async_to_sync( - multipart_upload_file_async(self, filepath, content_type="text/csv"), self + multipart_upload_file_async(self, filepath, content_type="text/csv") ) uploadRequest = { @@ -8009,8 +8003,7 @@ async def async_csv_download(): entity_type="TableEntity", destination=os.path.join(download_dir, filename), synapse_client=self, - ), - syn=self, + ) ) return download_from_table_result, path @@ -8310,8 +8303,7 @@ def downloadTableColumns(self, table, columns, downloadLocation=None, **kwargs): synapse_id=table.tableId, entity_type="TableEntity", destination=zipfilepath, - ), - syn=self, + ) ) # TODO handle case when no zip file is returned # TODO test case when we give it partial or all bad file handles @@ -8578,8 +8570,7 @@ def sendMessage( """ fileHandleId = wrap_async_to_sync( - multipart_upload_string_async(self, messageBody, content_type=contentType), - self, + multipart_upload_string_async(self, messageBody, content_type=contentType) ) message = dict( recipients=userIds, subject=messageSubject, fileHandleId=fileHandleId diff --git a/synapseclient/core/async_utils.py b/synapseclient/core/async_utils.py index 574f11903..3eb51d839 100644 --- a/synapseclient/core/async_utils.py +++ b/synapseclient/core/async_utils.py @@ -2,14 +2,11 @@ import asyncio import functools -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Union +from typing import Any, Callable, Coroutine, Union import nest_asyncio from opentelemetry import trace -if TYPE_CHECKING: - from synapseclient import Synapse - tracer = trace.get_tracer("synapseclient") @@ -77,7 +74,7 @@ def f(*args, **kwds): return f -def wrap_async_to_sync(coroutine: Coroutine[Any, Any, Any], syn: "Synapse") -> Any: +def wrap_async_to_sync(coroutine: Coroutine[Any, Any, Any]) -> Any: """Wrap an async function to be called in a sync context.""" loop = None diff --git a/synapseclient/core/upload/multipart_upload_async.py b/synapseclient/core/upload/multipart_upload_async.py index b078e2797..55085d8ee 100644 --- a/synapseclient/core/upload/multipart_upload_async.py +++ b/synapseclient/core/upload/multipart_upload_async.py @@ -329,8 +329,7 @@ def _refresh_pre_signed_part_urls( self._fetch_pre_signed_part_urls_async( self._upload_id, list(self._pre_signed_part_urls.keys()), - ), - syn=self._syn, + ) ) refreshed_url = self._pre_signed_part_urls[part_number] diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index cd7534b3e..dcd700fca 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -11,6 +11,7 @@ from synapseclient.models.entityview import EntityView, ViewTypeMask from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder +from synapseclient.models.link import Link from synapseclient.models.materializedview import MaterializedView from synapseclient.models.mixins.table_components import QueryMixin from synapseclient.models.project import Project @@ -57,6 +58,7 @@ "File", "FileHandle", "Folder", + "Link", "Project", "Annotations", "Team", diff --git a/synapseclient/models/dataset.py b/synapseclient/models/dataset.py index e1e2567a6..96e6910d1 100644 --- a/synapseclient/models/dataset.py +++ b/synapseclient/models/dataset.py @@ -1022,9 +1022,7 @@ def add_item( entity_ref=EntityRef(id=item.id, version=item.version_number) ) elif isinstance(item, Folder): - children = wrap_async_to_sync( - item._retrieve_children(follow_link=True), client - ) + children = wrap_async_to_sync(item._retrieve_children(follow_link=True)) for child in children: if child["type"] == concrete_types.FILE_ENTITY: self._append_entity_ref( @@ -1129,9 +1127,7 @@ def remove_item( ).get() self._remove_entity_ref(EntityRef(id=item.id, version=item.version_number)) elif isinstance(item, Folder): - children = wrap_async_to_sync( - item._retrieve_children(follow_link=True), client - ) + children = wrap_async_to_sync(item._retrieve_children(follow_link=True)) for child in children: if child["type"] == concrete_types.FILE_ENTITY: self._remove_entity_ref( diff --git a/synapseclient/models/folder.py b/synapseclient/models/folder.py index 6ea136dd7..8e7d1d262 100644 --- a/synapseclient/models/folder.py +++ b/synapseclient/models/folder.py @@ -20,6 +20,7 @@ ) from synapseclient.models.protocols.folder_protocol import FolderSynchronousProtocol from synapseclient.models.services.search import get_id +from synapseclient.models.services.storable_entity import store_entity from synapseclient.models.services.storable_entity_components import ( FailureStrategy, store_entity_components, @@ -321,7 +322,6 @@ async def store_async( } ) if self.has_changed: - loop = asyncio.get_event_loop() synapse_folder = Synapse_Folder( id=self.id, name=self.name, @@ -330,14 +330,10 @@ async def store_async( description=self.description, ) delete_none_keys(synapse_folder) - entity = await loop.run_in_executor( - None, - lambda: Synapse.get_client(synapse_client=synapse_client).store( - obj=synapse_folder, - set_annotations=False, - isRestricted=self.is_restricted, - createOrUpdate=False, - ), + entity = await store_entity( + resource=self, + entity=synapse_folder, + synapse_client=synapse_client, ) self.fill_from_dict(synapse_folder=entity, set_annotations=False) diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py new file mode 100644 index 000000000..1c317aea0 --- /dev/null +++ b/synapseclient/models/link.py @@ -0,0 +1,636 @@ +"""Link dataclass model for Synapse entities.""" + +import dataclasses +from copy import deepcopy +from dataclasses import dataclass, field, replace +from datetime import date, datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union + +from synapseclient import Synapse +from synapseclient.api import get_from_entity_factory +from synapseclient.core.async_utils import async_to_sync, otel_trace_method +from synapseclient.core.constants.concrete_types import LINK_ENTITY +from synapseclient.core.utils import delete_none_keys, merge_dataclass_entities +from synapseclient.models import Activity, Annotations +from synapseclient.models.services.search import get_id +from synapseclient.models.services.storable_entity import store_entity +from synapseclient.models.services.storable_entity_components import ( + store_entity_components, +) + +if TYPE_CHECKING: + from synapseclient.models import ( + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + MaterializedView, + Project, + SubmissionView, + Table, + VirtualTable, + ) + from synapseclient.operations.factory_operations import FileOptions + + +class LinkSynchronousProtocol(Protocol): + """Protocol defining the synchronous interface for Link operations.""" + + def get( + self, + parent: Optional[Union["Folder", "Project"]] = None, + follow_link: bool = True, + file_options: Optional["FileOptions"] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", + "Link", + ]: + """Get the link metadata from Synapse. You are able to find a link by + either the id or the name and parent_id. + + Arguments: + parent: The parent folder or project this link exists under. + follow_link: If True then the entity this link points to will be fetched + and returned instead of the Link entity itself. + file_options: Options that modify file retrieval. Only used if `follow_link` + is True and the link points to a File entity. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The link object. + + Raises: + ValueError: If the link does not have an id or a + (name and (`parent_id` or parent with an id)) set. + + Example: Using this function + Retrieve a link and follow it to get the target entity: + + ```python + from synapseclient import Synapse + from synapseclient.models import Link + + syn = Synapse() + syn.login() + + # Get the target entity that the link points to + target_entity = Link(id="syn123").get() + ``` + + Retrieve only the link metadata without following the link: + + ```python + from synapseclient import Synapse + from synapseclient.models import Link + + syn = Synapse() + syn.login() + + # Get just the link entity itself + link_entity = Link(id="syn123").get(follow_link=False) + ``` + + When the link points to a File, you can specify file download options: + + ```python + from synapseclient import Synapse + from synapseclient.models import Link, FileOptions + + syn = Synapse() + syn.login() + + # Follow link to file with custom download options + file_entity = Link(id="syn123").get( + file_options=FileOptions( + download_file=True, + download_location="/path/to/downloads/", + if_collision="overwrite.local" + ) + ) + ``` + """ + return self + + def store( + self, + parent: Optional[Union["Folder", "Project"]] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> "Link": + """Store the link in Synapse. + + Arguments: + parent: The parent folder or project to store the link in. May also be + specified in the Link object. If both are provided the parent passed + into `store` will take precedence. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The link object. + + Raises: + ValueError: If the link does not have a name and parent_id, or target_id. + + Example: Using this function + Link with the name `my_link` referencing entity `syn123` and parent folder `syn456`: + + ```python + from synapseclient import Synapse + from synapseclient.models import Link + + syn = Synapse() + syn.login() + + link_instance = Link( + name="my_link", + parent_id="syn456", + target_id="syn123" + ).store() + ``` + """ + return self + + +@dataclass() +@async_to_sync +class Link(LinkSynchronousProtocol): + """A Link entity within Synapse that references another entity. + + Represents a [Synapse Link](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/Link.html). + + Attributes: + name: The name of this entity. Must be 256 characters or less. Names may only + contain: letters, numbers, spaces, underscores, hyphens, periods, plus signs, + apostrophes, and parentheses + description: The description of this entity. Must be 1000 characters or less. + id: The unique immutable ID for this entity. A new ID will be generated for new + Entities. Once issued, this ID is guaranteed to never change or be re-issued + etag: Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle + concurrent updates. Since the E-Tag changes every time an entity is updated + it is used to detect when a client's current representation of an entity is + out-of-date. + created_on: (Read Only) The date this entity was created. + modified_on: (Read Only) The date this entity was last modified. + created_by: (Read Only) The ID of the user that created this entity. + modified_by: (Read Only) The ID of the user that last modified this entity. + parent_id: The ID of the Entity that is the parent of this Entity. + concrete_type: Indicates which implementation of Entity this object represents. + The value is the fully qualified class name, e.g. org.sagebionetworks.repo.model.FileEntity. + target_id: The ID of the entity to which this link refers + target_version_number: The version number of the entity to which this link refers + links_to_class_name: The synapse Entity's class name that this link points to. + activity: The Activity model represents the main record of Provenance in + Synapse. It is analogous to the Activity defined in the + W3C Specification on Provenance. Activity cannot be removed during a store + operation by setting it to None. You must use Activity.delete_async or + Activity.disassociate_from_entity_async. + annotations: Additional metadata associated with the link. The key is the name + of your desired annotations. The value is an object containing a list of + values (use empty list to represent no values for key) and the value type + associated with all values in the list. To remove all annotations set this + to an empty dict {} or None and store the entity. + """ + + name: Optional[str] = None + """The name of this entity. Must be 256 characters or less. Names may only contain: + letters, numbers, spaces, underscores, hyphens, periods, plus signs, apostrophes, + and parentheses""" + + description: Optional[str] = None + """The description of this entity. Must be 1000 characters or less.""" + + id: Optional[str] = None + """The unique immutable ID for this entity. A new ID will be generated for new + Entities. Once issued, this ID is guaranteed to never change or be re-issued""" + + etag: Optional[str] = None + """Synapse employs an Optimistic Concurrency Control (OCC) scheme to handle + concurrent updates. Since the E-Tag changes every time an entity is updated + it is used to detect when a client's current representation of an entity is + out-of-date.""" + + created_on: Optional[str] = None + """(Read Only) The date this entity was created.""" + + modified_on: Optional[str] = None + """(Read Only) The date this entity was last modified.""" + + created_by: Optional[str] = None + """(Read Only) The ID of the user that created this entity.""" + + modified_by: Optional[str] = None + """(Read Only) The ID of the user that last modified this entity.""" + + parent_id: Optional[str] = None + """The ID of the Entity that is the parent of this Entity.""" + + target_id: Optional[str] = None + """The ID of the entity to which this link refers""" + + target_version_number: Optional[int] = None + """The version number of the entity to which this link refers""" + + links_to_class_name: Optional[str] = None + """The synapse Entity's class name that this link points to.""" + + activity: Optional[Activity] = field(default=None, compare=False) + """The Activity model represents the main record of Provenance in Synapse. It is + analogous to the Activity defined in the W3C Specification on Provenance. Activity + cannot be removed during a store operation by setting it to None. You must use + Activity.delete_async or Activity.disassociate_from_entity_async.""" + + annotations: Optional[ + Dict[ + str, + Union[ + List[str], + List[bool], + List[float], + List[int], + List[date], + List[datetime], + ], + ] + ] = field(default_factory=dict, compare=False) + """Additional metadata associated with the link. The key is the name of your + desired annotations. The value is an object containing a list of values + (use empty list to represent no values for key) and the value type associated with + all values in the list. To remove all annotations set this to an empty dict {} or + None and store the entity.""" + + _last_persistent_instance: Optional["Link"] = field( + default=None, repr=False, compare=False + ) + """The last persistent instance of this object. This is used to determine if the + object has been changed and needs to be updated in Synapse.""" + + @property + def has_changed(self) -> bool: + """Determines if the object has been changed and needs to be updated in Synapse.""" + return ( + not self._last_persistent_instance or self._last_persistent_instance != self + ) + + def _set_last_persistent_instance(self) -> None: + """Stash the last time this object interacted with Synapse. This is used to + determine if the object has been changed and needs to be updated in Synapse.""" + del self._last_persistent_instance + self._last_persistent_instance = replace(self) + self._last_persistent_instance.activity = ( + dataclasses.replace(self.activity) if self.activity else None + ) + self._last_persistent_instance.annotations = ( + deepcopy(self.annotations) if self.annotations else {} + ) + + def fill_from_dict( + self, synapse_entity: Dict[str, Any], set_annotations: bool = True + ) -> "Link": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_entity: The response from the REST API. + set_annotations: Whether to set the annotations from the response. + + Returns: + The Link object. + """ + self.name = synapse_entity.get("name", None) + self.description = synapse_entity.get("description", None) + self.id = synapse_entity.get("id", None) + self.etag = synapse_entity.get("etag", None) + self.created_on = synapse_entity.get("createdOn", None) + self.modified_on = synapse_entity.get("modifiedOn", None) + self.created_by = synapse_entity.get("createdBy", None) + self.modified_by = synapse_entity.get("modifiedBy", None) + self.parent_id = synapse_entity.get("parentId", None) + + # Handle nested Reference object + links_to_data = synapse_entity.get("linksTo", None) + if links_to_data: + self.target_id = links_to_data.get("targetId", None) + self.target_version_number = links_to_data.get("targetVersionNumber", None) + else: + self.target_id = None + self.target_version_number = None + + self.links_to_class_name = synapse_entity.get("linksToClassName", None) + + if set_annotations: + self.annotations = Annotations.from_dict( + synapse_entity.get("annotations", {}) + ) + + return self + + def to_synapse_request(self) -> Dict[str, Any]: + """ + Converts this dataclass to a dictionary suitable for a Synapse REST API request. + + Returns: + A dictionary representation of this object for API requests. + """ + request_dict = { + "name": self.name, + "description": self.description, + "id": self.id, + "etag": self.etag, + "createdOn": self.created_on, + "modifiedOn": self.modified_on, + "createdBy": self.created_by, + "modifiedBy": self.modified_by, + "parentId": self.parent_id, + "concreteType": LINK_ENTITY, + "linksTo": { + "targetId": self.target_id, + "targetVersionNumber": self.target_version_number, + } + if self.target_id + else None, + "linksToClassName": self.links_to_class_name, + } + if request_dict["linksTo"]: + delete_none_keys(request_dict["linksTo"]) + delete_none_keys(request_dict) + return request_dict + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Link_Get: {self.id}" + ) + async def get_async( + self, + parent: Optional[Union["Folder", "Project"]] = None, + follow_link: bool = True, + file_options: Optional["FileOptions"] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", + "Link", + ]: + """Get the link metadata from Synapse. You are able to find a link by + either the id or the name and parent_id. + + Arguments: + parent: The parent folder or project this link exists under. + follow_link: If True then the entity this link points to will be fetched + and returned instead of the Link entity itself. + file_options: Options that modify file retrieval. Only used if `follow_link` + is True and the link points to a File entity. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The link object. + + Raises: + ValueError: If the link does not have an id or a + (name and (`parent_id` or parent with an id)) set. + + Example: Using this function + Retrieve a link and follow it to get the target entity: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Link + + async def get_link_target(): + syn = Synapse() + syn.login() + + # Get the target entity that the link points to + target_entity = await Link(id="syn123").get_async() + return target_entity + + # Run the async function + target_entity = asyncio.run(get_link_target()) + ``` + + Retrieve only the link metadata without following the link: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Link + + async def get_link_metadata(): + syn = Synapse() + syn.login() + + # Get just the link entity itself + link_entity = await Link(id="syn123").get_async(follow_link=False) + return link_entity + + # Run the async function + link_entity = asyncio.run(get_link_metadata()) + ``` + + When the link points to a File, you can specify file download options: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Link, FileOptions + + async def get_link_with_file_options(): + syn = Synapse() + syn.login() + + # Follow link to file with custom download options + file_entity = await Link(id="syn123").get_async( + file_options=FileOptions( + download_file=True, + download_location="/path/to/downloads/", + if_collision="overwrite.local" + ) + ) + return file_entity + + # Run the async function + file_entity = asyncio.run(get_link_with_file_options()) + ``` + """ + parent_id = parent.id if parent else self.parent_id + if not (self.id or (self.name and parent_id)): + raise ValueError( + "The link must have an id or a " + "(name and (`parent_id` or parent with an id)) set." + ) + self.parent_id = parent_id + + entity_id = await get_id(entity=self, synapse_client=synapse_client) + + await get_from_entity_factory( + synapse_id_or_path=entity_id, + entity_to_update=self, + synapse_client=synapse_client, + ) + self._set_last_persistent_instance() + + if follow_link: + from synapseclient.operations.factory_operations import ( + get_async as factory_get_async, + ) + + return await factory_get_async( + synapse_id=self.target_id, + version_number=self.target_version_number, + file_options=file_options, + synapse_client=synapse_client, + ) + else: + return self + + @otel_trace_method( + method_to_trace_name=lambda self, **kwargs: f"Link_Store: {self.name}" + ) + async def store_async( + self, + parent: Optional[Union["Folder", "Project"]] = None, + *, + synapse_client: Optional[Synapse] = None, + ) -> "Link": + """Store the link in Synapse. + + Arguments: + parent: The parent folder or project to store the link in. May also be + specified in the Link object. If both are provided the parent passed + into `store` will take precedence. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The link object. + + Raises: + ValueError: If the link does not have a name and parent_id, or target_id. + + Example: Using this function + Link with the name `my_link` referencing entity `syn123` and parent folder `syn456`: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import Link + + async def store_link(): + syn = Synapse() + syn.login() + + link_instance = await Link( + name="my_link", + parent_id="syn456", + target_id="syn123" + ).store_async() + return link_instance + + # Run the async function + link_instance = asyncio.run(store_link()) + ``` + """ + if parent: + self.parent_id = parent.id + + if not self.name and not self.id: + raise ValueError("The link must have a name.") + if not self.parent_id and not self.id: + raise ValueError("The link must have a parent_id.") + if not self.target_id and not self.id: + raise ValueError("The link must have a target_id.") + + if existing_entity := await self._find_existing_entity( + synapse_client=synapse_client + ): + merge_dataclass_entities( + source=existing_entity, + destination=self, + ) + + if self.has_changed: + entity = await store_entity( + resource=self, + entity=self.to_synapse_request(), + synapse_client=synapse_client, + ) + + self.fill_from_dict(synapse_entity=entity, set_annotations=False) + + re_read_required = await store_entity_components( + root_resource=self, synapse_client=synapse_client + ) + if re_read_required: + await self.get_async( + synapse_client=synapse_client, + ) + + self._set_last_persistent_instance() + return self + + async def _find_existing_entity( + self, *, synapse_client: Optional[Synapse] = None + ) -> Union["Link", None]: + """Determines if the entity already exists in Synapse. If it does it will return + the entity object, otherwise it will return None. This is used to determine if the + entity should be updated or created.""" + + async def get_link(existing_id: str) -> "Link": + """Small wrapper to retrieve a link instance without raising an error if it + does not exist. + + Arguments: + existing_id: The ID of the entity to retrieve. + + Returns: + The entity object if it exists, otherwise None. + """ + link_copy = Link( + id=existing_id, + parent_id=self.parent_id, + ) + return await link_copy.get_async( + synapse_client=synapse_client, + follow_link=False, + ) + + if ( + not self._last_persistent_instance + and ( + existing_entity_id := await get_id( + entity=self, + failure_strategy=None, + synapse_client=synapse_client, + ) + ) + and (existing_entity := await get_link(existing_entity_id)) + ): + return existing_entity + return None diff --git a/synapseclient/models/mixins/table_components.py b/synapseclient/models/mixins/table_components.py index 9e9fa3040..fc9245f9a 100644 --- a/synapseclient/models/mixins/table_components.py +++ b/synapseclient/models/mixins/table_components.py @@ -363,7 +363,7 @@ async def _table_query( - For `csv`, you can specify: - `quote_character`: Character used for quoting fields. Default is double quote ("). - - `escape_character`: Character used for escaping special characters. Default is backslash (\). + - `escape_character`: Character used for escaping special characters. Default is backslash. - `line_end`: Character(s) used to terminate lines. Default is system line separator. - `separator`: Character used to separate fields. Default is comma (,). - `header`: Whether to include a header row. Default is True. diff --git a/synapseclient/models/project.py b/synapseclient/models/project.py index 87b2024e8..eb9a6e281 100644 --- a/synapseclient/models/project.py +++ b/synapseclient/models/project.py @@ -20,6 +20,7 @@ ) from synapseclient.models.protocols.project_protocol import ProjectSynchronousProtocol from synapseclient.models.services.search import get_id +from synapseclient.models.services.storable_entity import store_entity from synapseclient.models.services.storable_entity_components import ( FailureStrategy, store_entity_components, @@ -354,7 +355,6 @@ async def store_async( } ) if self.has_changed: - loop = asyncio.get_event_loop() synapse_project = Synapse_Project( id=self.id, etag=self.etag, @@ -364,13 +364,10 @@ async def store_async( parentId=self.parent_id, ) delete_none_keys(synapse_project) - entity = await loop.run_in_executor( - None, - lambda: Synapse.get_client(synapse_client=synapse_client).store( - obj=synapse_project, - set_annotations=False, - createOrUpdate=False, - ), + entity = await store_entity( + resource=self, + entity=synapse_project, + synapse_client=synapse_client, ) self.fill_from_dict(synapse_project=entity, set_annotations=False) diff --git a/synapseclient/models/services/storable_entity.py b/synapseclient/models/services/storable_entity.py index d70082e80..4bf4c31f4 100644 --- a/synapseclient/models/services/storable_entity.py +++ b/synapseclient/models/services/storable_entity.py @@ -13,11 +13,11 @@ from synapseclient.core.utils import get_properties if TYPE_CHECKING: - from synapseclient.models import File, Folder, Project + from synapseclient.models import File, Folder, Link, Project async def store_entity( - resource: Union["File", "Folder", "Project"], + resource: Union["File", "Folder", "Project", "Link"], entity: Dict[str, Union[str, bool, int, float]], *, synapse_client: Optional[Synapse] = None, @@ -65,27 +65,6 @@ async def store_entity( synapse_client=synapse_client, ) else: - # TODO - When Link is implemented this needs to be completed - # If Link, get the target name, version number and concrete type and store in link properties - # if properties["concreteType"] == "org.sagebionetworks.repo.model.Link": - # target_properties = self._getEntity( - # properties["linksTo"]["targetId"], - # version=properties["linksTo"].get("targetVersionNumber"), - # ) - # if target_properties["parentId"] == properties["parentId"]: - # raise ValueError( - # "Cannot create a Link to an entity under the same parent." - # ) - # properties["linksToClassName"] = target_properties["concreteType"] - # if ( - # target_properties.get("versionNumber") is not None - # and properties["linksTo"].get("targetVersionNumber") is not None - # ): - # properties["linksTo"]["targetVersionNumber"] = target_properties[ - # "versionNumber" - # ] - # properties["name"] = target_properties["name"] - updated_entity = await post_entity( request=get_properties(entity), synapse_client=synapse_client, diff --git a/synapseclient/models/services/storable_entity_components.py b/synapseclient/models/services/storable_entity_components.py index 76c7622da..f2c2f725d 100644 --- a/synapseclient/models/services/storable_entity_components.py +++ b/synapseclient/models/services/storable_entity_components.py @@ -8,12 +8,16 @@ if TYPE_CHECKING: from synapseclient.models import ( Dataset, + DatasetCollection, EntityView, File, Folder, + Link, + MaterializedView, Project, SubmissionView, Table, + VirtualTable, ) @@ -50,7 +54,19 @@ async def wrap_coroutine( async def store_entity_components( - root_resource: Union["File", "Folder", "Project", "Table", "Dataset", "EntityView"], + root_resource: Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "Project", + "MaterializedView", + "SubmissionView", + "Table", + "VirtualTable", + ], failure_strategy: FailureStrategy = FailureStrategy.LOG_EXCEPTION, *, synapse_client: Optional[Synapse] = None, @@ -116,7 +132,6 @@ async def store_entity_components( if failure_strategy == FailureStrategy.RAISE_EXCEPTION: raise ex - # TODO: Double check this logic. This might not be getting set properly from _resolve_store_task return re_read_required @@ -219,7 +234,19 @@ def _pull_activity_forward_to_new_version( async def _store_activity_and_annotations( - root_resource: Union["File", "Folder", "Project", "Table", "Dataset", "EntityView"], + root_resource: Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "Project", + "MaterializedView", + "SubmissionView", + "Table", + "VirtualTable", + ], *, synapse_client: Optional[Synapse] = None, ) -> bool: diff --git a/synapseclient/operations/__init__.py b/synapseclient/operations/__init__.py new file mode 100644 index 000000000..9dd789acc --- /dev/null +++ b/synapseclient/operations/__init__.py @@ -0,0 +1,17 @@ +from synapseclient.operations.factory_operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, + get, + get_async, +) + +__all__ = [ + "ActivityOptions", + "FileOptions", + "TableOptions", + "LinkOptions", + "get", + "get_async", +] diff --git a/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py new file mode 100644 index 000000000..1c17bd7ff --- /dev/null +++ b/synapseclient/operations/factory_operations.py @@ -0,0 +1,1221 @@ +"""Factory method for retrieving entities by Synapse ID.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Union + +from synapseclient.core.async_utils import wrap_async_to_sync +from synapseclient.core.exceptions import SynapseNotFoundError + +if TYPE_CHECKING: + from synapseclient import Synapse + from synapseclient.models import ( + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + Link, + MaterializedView, + Project, + SubmissionView, + Table, + VirtualTable, + ) + + +@dataclass +class FileOptions: + """ + Configuration options specific to File entities when using the factory methods. + + This dataclass allows you to customize how File entities are handled during + retrieval, including download behavior, file location, and collision handling. + + Attributes: + download_file: Whether to automatically download the file content when + retrieving the File entity. If True, the file will be downloaded to + the local filesystem. If False, only the metadata will be retrieved. + Default is True. + download_location: The local directory path where the file should be + downloaded. If None, the file will be downloaded to the default Synapse + cache location. If specified, + must be a valid directory path. Default is None. + if_collision: Strategy to use when a file with the same name already + exists at the download location. Valid options are: + - "keep.both": Keep both files by appending a number to the new file + - "overwrite.local": Overwrite the existing local file + - "keep.local": Keep the existing local file and skip download + Default is "keep.both". + + Example: + Configure file download options: + + ```python + from synapseclient.operations import FileOptions + + # Download file to specific location with overwrite + file_options = FileOptions( + download_file=True, + download_location="/path/to/downloads/", + if_collision="overwrite.local" + ) + + # Only retrieve metadata, don't download file content + metadata_only = FileOptions(download_file=False) + ``` + """ + + download_file: bool = True + download_location: Optional[str] = None + if_collision: str = "keep.both" + + +@dataclass +class ActivityOptions: + """ + Configuration options for entities that support activity/provenance tracking. + + This dataclass controls whether activity information (provenance data) should + be included when retrieving entities. Activity information tracks the computational + steps, data sources, and relationships that led to the creation of an entity. + + Attributes: + include_activity: Whether to include activity/provenance information when + retrieving the entity. If True, the returned entity will have its + activity attribute populated with provenance data (if available). + If False, the activity attribute will be None. Including activity + may result in additional API calls and slower retrieval times. + Default is False. + + Example: + Configure activity inclusion: + + ```python + from synapseclient.operations import ActivityOptions + + # Include activity information + with_activity = ActivityOptions(include_activity=True) + + # Skip activity information (faster retrieval) + without_activity = ActivityOptions(include_activity=False) + ``` + + Note: + Activity information is only available for entities that support provenance + tracking (File, Table, Dataset, etc...). For other entity + types, this option is ignored. + """ + + include_activity: bool = False + + +@dataclass +class TableOptions: + """ + Configuration options for table-like entities when using the factory methods. + + This dataclass controls how table-like entities (Table, Dataset, EntityView, + MaterializedView, SubmissionView, VirtualTable, and DatasetCollection) are + retrieved, particularly whether column schema information should be included. + + Attributes: + include_columns: Whether to include column schema information when + retrieving table-like entities. If True, the returned entity will + have its columns attribute populated with Column objects containing + schema information (name, column_type, etc.). If False, the columns + attribute will be an empty dict. Including columns may result in + additional API calls but provides complete table structure information. + Default is True. + + Example: + Configure table column inclusion: + + ```python + from synapseclient.operations import TableOptions + + # Include column schema information + with_columns = TableOptions(include_columns=True) + + # Skip column information (faster retrieval) + without_columns = TableOptions(include_columns=False) + ``` + """ + + include_columns: bool = True + + +@dataclass +class LinkOptions: + """ + Configuration options specific to Link entities when using the factory methods. + + This dataclass controls how Link entities are handled during retrieval, + particularly whether the link should be followed to return the target entity + or if the Link entity itself should be returned. + + Attributes: + follow_link: Whether to follow the link and return the target entity + instead of the Link entity itself. If True, the factory method will + return the entity that the Link points to (e.g., if a Link points + to a File, a File object will be returned). If False, the Link + entity itself will be returned, allowing you to inspect the link's + properties such as target_id, target_version, etc. Default is True. + + Example: + Configure link following behavior: + + ```python + from synapseclient.operations import LinkOptions + + # Follow the link and return the target entity + follow_target = LinkOptions(follow_link=True) + + # Return the Link entity itself + return_link = LinkOptions(follow_link=False) + ``` + + Note: + - When follow_link=True, the returned entity type depends on what the + Link points to (could be File, Project, Folder, etc.) + - When follow_link=False, a Link entity is always returned + """ + + follow_link: bool = True + + +async def _handle_entity_instance( + entity, + version_number: Optional[int] = None, + activity_options: Optional[ActivityOptions] = None, + file_options: Optional[FileOptions] = None, + table_options: Optional[TableOptions] = None, + link_options: Optional[LinkOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", +]: + """ + Handle the case where an entity instance is passed directly to get_async. + + This private function encapsulates the logic for applying options and calling + get_async on an existing entity instance. + """ + from synapseclient.models import ( + Dataset, + DatasetCollection, + EntityView, + File, + Link, + MaterializedView, + SubmissionView, + Table, + VirtualTable, + ) + + if version_number is not None and hasattr(entity, "version_number"): + entity.version_number = version_number + + get_kwargs = {"synapse_client": synapse_client} + + if activity_options and activity_options.include_activity: + get_kwargs["include_activity"] = True + + table_like_entities = ( + Dataset, + DatasetCollection, + EntityView, + MaterializedView, + SubmissionView, + Table, + VirtualTable, + ) + if table_options and isinstance(entity, table_like_entities): + get_kwargs["include_columns"] = table_options.include_columns + + if file_options and isinstance(entity, File): + if hasattr(file_options, "download_file"): + entity.download_file = file_options.download_file + if ( + hasattr(file_options, "download_location") + and file_options.download_location + ): + entity.path = file_options.download_location + if hasattr(file_options, "if_collision"): + entity.if_collision = file_options.if_collision + + if link_options and isinstance(entity, Link): + if hasattr(link_options, "follow_link"): + get_kwargs["follow_link"] = link_options.follow_link + + return await entity.get_async(**get_kwargs) + + +async def _handle_simple_entity( + entity_class, + synapse_id: str, + version_number: Optional[int] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union["Project", "Folder"]: + """ + Handle simple entities that only need basic setup (Project, Folder, DatasetCollection). + """ + entity = entity_class(id=synapse_id) + if version_number and hasattr(entity, "version_number"): + entity.version_number = version_number + return await entity.get_async(synapse_client=synapse_client) + + +async def _handle_table_like_entity( + entity_class, + synapse_id: str, + version_number: Optional[int] = None, + activity_options: Optional[ActivityOptions] = None, + table_options: Optional[TableOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "MaterializedView", + "SubmissionView", + "Table", + "VirtualTable", +]: + """ + Handle table-like entities (Table, Dataset, EntityView, MaterializedView, SubmissionView, VirtualTable). + """ + entity = entity_class(id=synapse_id) + if version_number: + entity.version_number = version_number + + kwargs = {"synapse_client": synapse_client} + + if table_options: + kwargs["include_columns"] = table_options.include_columns + if activity_options and activity_options.include_activity: + kwargs["include_activity"] = True + + return await entity.get_async(**kwargs) + + +async def _handle_file_entity( + synapse_id: str, + version_number: Optional[int] = None, + activity_options: Optional[ActivityOptions] = None, + file_options: Optional[FileOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> "File": + """ + Handle File entities with file-specific options. + """ + from synapseclient.models import File + + file_kwargs = {"id": synapse_id} + + if version_number: + file_kwargs["version_number"] = version_number + + if file_options: + file_kwargs["download_file"] = file_options.download_file + if file_options.download_location: + file_kwargs["path"] = file_options.download_location + file_kwargs["if_collision"] = file_options.if_collision + + entity = File(**file_kwargs) + + get_kwargs = {"synapse_client": synapse_client} + + if activity_options and activity_options.include_activity: + get_kwargs["include_activity"] = True + + return await entity.get_async(**get_kwargs) + + +async def _handle_link_entity( + synapse_id: str, + link_options: Optional[LinkOptions] = None, + file_options: Optional[FileOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", +]: + """ + Handle Link entities with link-specific options. + + Note: Links don't support versioning, so version_number is not included. + """ + from synapseclient.models import Link + + entity = Link(id=synapse_id) + + kwargs = {"synapse_client": synapse_client} + + if link_options: + kwargs["follow_link"] = link_options.follow_link + + if file_options: + kwargs["file_options"] = file_options + + return await entity.get_async(**kwargs) + + +def get( + synapse_id: Optional[str] = None, + *, + entity_name: Optional[str] = None, + parent_id: Optional[str] = None, + version_number: Optional[int] = None, + activity_options: Optional[ActivityOptions] = None, + file_options: Optional[FileOptions] = None, + table_options: Optional[TableOptions] = None, + link_options: Optional[LinkOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", +]: + """ + Factory method to retrieve any Synapse entity by its ID or by name and parent ID. + + This method serves as a unified interface for retrieving any type of Synapse entity + without needing to know the specific entity type beforehand. It automatically + determines the entity type and returns the appropriate model instance. + + You can retrieve entities in two ways: + + 1. By providing a synapse_id directly + 2. By providing entity_name and optionally parent_id for lookup + + Arguments: + synapse_id: The Synapse ID of the entity to retrieve (e.g., 'syn123456'). + Mutually exclusive with entity_name. + entity_name: The name of the entity to find. Must be used with this approach + instead of synapse_id. When looking up projects, parent_id should be None. + parent_id: The parent entity ID when looking up by name. Set to None when + looking up projects by name. Only used with entity_name. + version_number: The specific version number of the entity to retrieve. Only + applies to versionable entities (File, Table, Dataset). If not specified, + the most recent version will be retrieved. Ignored for other entity types. + activity_options: Activity-specific configuration options. Can be applied to + any entity type to include activity information. + file_options: File-specific configuration options. Only applies to File entities. + Ignored for other entity types. + table_options: Table-specific configuration options. Only applies to Table-like + entities (Table, Dataset, EntityView, MaterializedView, SubmissionView, + VirtualTable, DatasetCollection). Ignored for other entity types. + link_options: Link-specific configuration options. Only applies when the entity + is a Link. Ignored for other entity types. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The appropriate Synapse entity model instance based on the entity type. + + Raises: + ValueError: If both synapse_id and entity_name are provided, or if neither is provided. + ValueError: If entity_name is provided without this being a valid lookup scenario. + ValueError: If the synapse_id is not a valid Synapse ID format. + + Note: + When using entity_name lookup: + + - For projects: leave parent_id=None + - For all other entities: provide the parent_id of the containing folder/project + + Example: Retrieving entities by ID + Get any entity by Synapse ID: + + ```python + from synapseclient import Synapse + from synapseclient.models import File, Project + from synapseclient.operations import get + + syn = Synapse() + syn.login() + + # Works for any entity type + entity = get(synapse_id="syn123456") + + # The returned object will be the appropriate type + if isinstance(entity, File): + print(f"File: {entity.name}") + elif isinstance(entity, Project): + print(f"Project: {entity.name}") + ``` + + Example: Retrieving entities by name + Get an entity by name and parent: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get + + syn = Synapse() + syn.login() + + # Get a file by name within a folder + entity = get( + entity_name="my_file.txt", + parent_id="syn123456" + ) + + # Get a project by name (parent_id=None) + project = get( + entity_name="My Project", + parent_id=None + ) + ``` + + Example: Retrieving a specific version + Get a specific version of a versionable entity: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get + + syn = Synapse() + syn.login() + + entity = get(synapse_id="syn123456", version_number=2) + ``` + + Example: Retrieving a file with custom options + Get file metadata with specific download options: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, FileOptions, ActivityOptions + + syn = Synapse() + syn.login() + + file_entity = get( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + file_options=FileOptions( + download_file=False + ) + ) + ``` + + Example: Retrieving a table with activity and columns + Get table with activity and column information: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, ActivityOptions, TableOptions + + syn = Synapse() + syn.login() + + table_entity = get( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + table_options=TableOptions(include_columns=True) + ) + ``` + + Example: Following links + Get the target of a link entity: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, LinkOptions + + syn = Synapse() + syn.login() + + target_entity = get( + synapse_id="syn123456", + link_options=LinkOptions(follow_link=True) + ) + ``` + + Example: Working with Link entities + Get a Link entity without following it: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, LinkOptions + + syn = Synapse() + syn.login() + + # Get the link entity itself + link_entity = get( + synapse_id="syn123456", # Example link ID + link_options=LinkOptions(follow_link=False) + ) + print(f"Link: {link_entity.name} -> {link_entity.target_id}") + + # Then follow the link to get the target + target_entity = get( + synapse_id="syn123456", + link_options=LinkOptions(follow_link=True) + ) + print(f"Target: {target_entity.name} (type: {type(target_entity).__name__})") + ``` + + Example: Comprehensive File options + Download file with custom location and collision handling: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, FileOptions + + syn = Synapse() + syn.login() + + file_entity = get( + synapse_id="syn123456", + file_options=FileOptions( + download_file=True, + download_location="/path/to/download/", + if_collision="overwrite.local" + ) + ) + print(f"Downloaded file: {file_entity.name} to {file_entity.path}") + ``` + + Example: Table options for table-like entities + Get table entities with column information: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, TableOptions + + syn = Synapse() + syn.login() + + # Works for Table, Dataset, EntityView, MaterializedView, + # SubmissionView, VirtualTable, and DatasetCollection + table_entity = get( + synapse_id="syn123456", # Example table ID + table_options=TableOptions(include_columns=True) + ) + print(f"Table: {table_entity.name} with {len(table_entity.columns)} columns") + ``` + + Example: Combining multiple options + Get a File with both activity and custom download options: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, FileOptions, ActivityOptions + + syn = Synapse() + syn.login() + + file_entity = get( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + file_options=FileOptions( + download_file=False + ) + ) + print(f"File: {file_entity.name} (activity included: {file_entity.activity is not None})") + ``` + + Example: Working with entity instances + Pass an existing entity instance to refresh or apply new options: + + ```python + from synapseclient import Synapse + from synapseclient.operations import get, FileOptions + + syn = Synapse() + syn.login() + + # Get an entity first + entity = get(synapse_id="syn123456") + print(f"Original entity: {entity.name}") + + # Then use the entity instance to get it again with different options + refreshed_entity = get( + entity, + file_options=FileOptions(download_file=False) + ) + print(f"Refreshed entity: {refreshed_entity.name} (download_file: {refreshed_entity.download_file})") + ``` + """ + return wrap_async_to_sync( + coroutine=get_async( + synapse_id=synapse_id, + entity_name=entity_name, + parent_id=parent_id, + version_number=version_number, + activity_options=activity_options, + file_options=file_options, + table_options=table_options, + link_options=link_options, + synapse_client=synapse_client, + ) + ) + + +async def get_async( + synapse_id: Optional[str] = None, + *, + entity_name: Optional[str] = None, + parent_id: Optional[str] = None, + version_number: Optional[int] = None, + activity_options: Optional[ActivityOptions] = None, + file_options: Optional[FileOptions] = None, + table_options: Optional[TableOptions] = None, + link_options: Optional[LinkOptions] = None, + synapse_client: Optional["Synapse"] = None, +) -> Union[ + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "MaterializedView", + "Project", + "SubmissionView", + "Table", + "VirtualTable", +]: + """ + Factory method to retrieve any Synapse entity by its ID or by name and parent ID. + + This method serves as a unified interface for retrieving any type of Synapse entity + without needing to know the specific entity type beforehand. It automatically + determines the entity type and returns the appropriate model instance. + + You can retrieve entities in two ways: + + 1. By providing a synapse_id directly + 2. By providing entity_name and optionally parent_id for lookup + + Arguments: + synapse_id: The Synapse ID of the entity to retrieve (e.g., 'syn123456'). + Mutually exclusive with entity_name. + entity_name: The name of the entity to find. Must be used with this approach + instead of synapse_id. When looking up projects, parent_id should be None. + parent_id: The parent entity ID when looking up by name. Set to None when + looking up projects by name. Only used with entity_name. + version_number: The specific version number of the entity to retrieve. Only + applies to versionable entities (File, Table, Dataset). If not specified, + the most recent version will be retrieved. Ignored for other entity types. + file_options: File-specific configuration options. Only applies to File entities. + Ignored for other entity types. + link_options: Link-specific configuration options. Only applies when the entity + is a Link. Ignored for other entity types. + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The appropriate Synapse entity model instance based on the entity type. + + Raises: + ValueError: If both synapse_id and entity_name are provided, or if neither is provided. + ValueError: If entity_name is provided without this being a valid lookup scenario. + ValueError: If the synapse_id is not a valid Synapse ID format. + + Note: + When using entity_name lookup: + + - For projects: leave parent_id=None + - For all other entities: provide the parent_id of the containing folder/project + + Example: Retrieving entities by ID + Get any entity by Synapse ID: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import File, Project + from synapseclient.operations import get_async + + async def main(): + syn = Synapse() + syn.login() + + # Works for any entity type + entity = await get_async(synapse_id="syn123456") + + # The returned object will be the appropriate type + if isinstance(entity, File): + print(f"File: {entity.name}") + elif isinstance(entity, Project): + print(f"Project: {entity.name}") + + asyncio.run(main()) + ``` + + Example: Retrieving entities by name + Get an entity by name and parent: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async + + async def main(): + syn = Synapse() + syn.login() + + # Get a file by name within a folder + entity = await get_async( + entity_name="my_file.txt", + parent_id="syn123456" + ) + + # Get a project by name (parent_id=None) + project = await get_async( + entity_name="My Project", + parent_id=None + ) + + asyncio.run(main()) + ``` + + Example: Retrieving a specific version + Get a specific version of a versionable entity: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async + + async def main(): + syn = Synapse() + syn.login() + + entity = await get_async(synapse_id="syn123456", version_number=2) + + asyncio.run(main()) + ``` + + Example: Retrieving a file with custom options + Get file metadata with specific download options: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, FileOptions, ActivityOptions + + async def main(): + syn = Synapse() + syn.login() + + file_entity = await get_async( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + file_options=FileOptions( + download_file=False + ) + ) + + asyncio.run(main()) + ``` + + Example: Retrieving a table with activity and columns + Get table with activity and column information: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, ActivityOptions, TableOptions + + async def main(): + syn = Synapse() + syn.login() + + table_entity = await get_async( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + table_options=TableOptions(include_columns=True) + ) + + asyncio.run(main()) + ``` + + Example: Following links + Get the target of a link entity: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, LinkOptions + + async def main(): + syn = Synapse() + syn.login() + + target_entity = await get_async( + synapse_id="syn123456", + link_options=LinkOptions(follow_link=True) + ) + + asyncio.run(main()) + ``` + + Example: Working with Link entities + Get a Link entity without following it: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, LinkOptions + + async def main(): + syn = Synapse() + syn.login() + + # Get the link entity itself + link_entity = await get_async( + synapse_id="syn123456", # Example link ID + link_options=LinkOptions(follow_link=False) + ) + print(f"Link: {link_entity.name} -> {link_entity.target_id}") + + # Then follow the link to get the target + target_entity = await get_async( + synapse_id="syn123456", + link_options=LinkOptions(follow_link=True) + ) + print(f"Target: {target_entity.name} (type: {type(target_entity).__name__})") + + asyncio.run(main()) + ``` + + Example: Comprehensive File options + Download file with custom location and collision handling: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, FileOptions + + async def main(): + syn = Synapse() + syn.login() + + file_entity = await get_async( + synapse_id="syn123456", + file_options=FileOptions( + download_file=True, + download_location="/path/to/download/", + if_collision="overwrite.local" + ) + ) + print(f"Downloaded file: {file_entity.name} to {file_entity.path}") + + asyncio.run(main()) + ``` + + Example: Table options for table-like entities + Get table entities with column information: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, TableOptions + + async def main(): + syn = Synapse() + syn.login() + + # Works for Table, Dataset, EntityView, MaterializedView, + # SubmissionView, VirtualTable, and DatasetCollection + table_entity = await get_async( + synapse_id="syn123456", # Example table ID + table_options=TableOptions(include_columns=True) + ) + print(f"Table: {table_entity.name} with {len(table_entity.columns)} columns") + + asyncio.run(main()) + ``` + + Example: Combining multiple options + Get a File with both activity and custom download options: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, FileOptions, ActivityOptions + + async def main(): + syn = Synapse() + syn.login() + + file_entity = await get_async( + synapse_id="syn123456", + activity_options=ActivityOptions(include_activity=True), + file_options=FileOptions( + download_file=False + ) + ) + print(f"File: {file_entity.name} (activity included: {file_entity.activity is not None})") + + asyncio.run(main()) + ``` + + Example: Working with entity instances + Pass an existing entity instance to refresh or apply new options: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.operations import get_async, FileOptions + + async def main(): + syn = Synapse() + syn.login() + + # Get an entity first + entity = await get_async(synapse_id="syn123456") + print(f"Original entity: {entity.name}") + + # Then use the entity instance to get it again with different options + refreshed_entity = await get_async( + entity, + file_options=FileOptions(download_file=False) + ) + print(f"Refreshed entity: {refreshed_entity.name} (download_file: {refreshed_entity.download_file})") + + asyncio.run(main()) + ``` + """ + from synapseclient.api.entity_bundle_services_v2 import ( + get_entity_id_bundle2, + get_entity_id_version_bundle2, + ) + from synapseclient.api.entity_services import get_child, get_entity_type + from synapseclient.core.constants import concrete_types + from synapseclient.models import ( + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + Link, + MaterializedView, + Project, + SubmissionView, + Table, + VirtualTable, + ) + + activity_options = activity_options or ActivityOptions() + file_options = file_options or FileOptions() + table_options = table_options or TableOptions() + link_options = link_options or LinkOptions() + + # Handle case where an entity instance is passed directly + entity_types = ( + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + Link, + MaterializedView, + Project, + SubmissionView, + Table, + VirtualTable, + ) + if isinstance(synapse_id, entity_types): + return await _handle_entity_instance( + entity=synapse_id, + version_number=version_number, + activity_options=activity_options, + file_options=file_options, + table_options=table_options, + link_options=link_options, + synapse_client=synapse_client, + ) + + # Validate input parameters + if synapse_id is not None and entity_name is not None: + raise ValueError( + "Cannot specify both synapse_id and entity_name. " + "Use synapse_id for direct lookup or entity_name with optional parent_id for name-based lookup." + ) + + if synapse_id is None and entity_name is None: + raise ValueError( + "Must specify either synapse_id or entity_name. " + "Use synapse_id for direct lookup or entity_name with optional parent_id for name-based lookup." + ) + + # If looking up by name, get the synapse_id first + if entity_name is not None and synapse_id is None: + synapse_id = await get_child( + entity_name=entity_name, parent_id=parent_id, synapse_client=synapse_client + ) + if synapse_id is None: + if parent_id is None: + raise SynapseNotFoundError( + f"Project with name '{entity_name}' not found." + ) + else: + raise SynapseNotFoundError( + f"Entity with name '{entity_name}' not found in parent '{parent_id}'." + ) + + entity_header = await get_entity_type( + entity_id=synapse_id, synapse_client=synapse_client + ) + entity_type = entity_header.type + + if entity_type == concrete_types.LINK_ENTITY: + return await _handle_link_entity( + synapse_id=synapse_id, + link_options=link_options, + file_options=file_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.FILE_ENTITY: + return await _handle_file_entity( + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + file_options=file_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.PROJECT_ENTITY: + return await _handle_simple_entity( + entity_class=Project, + synapse_id=synapse_id, + version_number=version_number, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.FOLDER_ENTITY: + return await _handle_simple_entity( + entity_class=Folder, + synapse_id=synapse_id, + version_number=version_number, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.TABLE_ENTITY: + return await _handle_table_like_entity( + entity_class=Table, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.DATASET_ENTITY: + return await _handle_table_like_entity( + entity_class=Dataset, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.DATASET_COLLECTION_ENTITY: + return await _handle_table_like_entity( + entity_class=DatasetCollection, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.ENTITY_VIEW: + return await _handle_table_like_entity( + entity_class=EntityView, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.MATERIALIZED_VIEW: + return await _handle_table_like_entity( + entity_class=MaterializedView, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.SUBMISSION_VIEW: + return await _handle_table_like_entity( + entity_class=SubmissionView, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + elif entity_type == concrete_types.VIRTUAL_TABLE: + return await _handle_table_like_entity( + entity_class=VirtualTable, + synapse_id=synapse_id, + version_number=version_number, + activity_options=activity_options, + table_options=table_options, + synapse_client=synapse_client, + ) + + else: + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + client.logger.warning( + "Unknown entity type: %s. Falling back to returning %s as a dictionary bundle matching " + "https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/entitybundle/v2/EntityBundle.html", + entity_type, + synapse_id, + ) + + # This allows the function to handle new entity types that may be added in the future + if version_number is not None: + return await get_entity_id_version_bundle2( + entity_id=synapse_id, + version=version_number, + synapse_client=synapse_client, + ) + else: + return await get_entity_id_bundle2( + entity_id=synapse_id, synapse_client=synapse_client + ) diff --git a/synapseutils/sync.py b/synapseutils/sync.py index 0d35170b2..8ead132de 100644 --- a/synapseutils/sync.py +++ b/synapseutils/sync.py @@ -191,8 +191,7 @@ def syncFromSynapse( follow_link=followLink, download_file=downloadFile, manifest=manifest, - ), - syn=syn, + ) ) files = [] @@ -1171,8 +1170,7 @@ def syncToSynapse( df, merge_existing_annotations, associate_activity_to_new_version, - ), - syn, + ) ) else: wrap_async_to_sync( @@ -1181,8 +1179,7 @@ def syncToSynapse( df, merge_existing_annotations, associate_activity_to_new_version, - ), - syn, + ) ) progress_bar.update(total_upload_size - progress_bar.n) progress_bar.close() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ac85b347e..d80b2d5e6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -106,7 +106,7 @@ async def project_model(request, syn: Synapse) -> Project_Model: ).store_async() def project_teardown() -> None: - wrap_async_to_sync(_cleanup(syn, [working_directory, proj.id]), syn) + wrap_async_to_sync(_cleanup(syn, [working_directory, proj.id])) request.addfinalizer(project_teardown) @@ -124,7 +124,7 @@ async def project(request, syn: Synapse) -> Project: proj = syn.store(Project(name="integration_test_project" + str(uuid.uuid4()))) def project_teardown(): - wrap_async_to_sync(_cleanup(syn, [working_directory, proj]), syn) + wrap_async_to_sync(_cleanup(syn, [working_directory, proj])) request.addfinalizer(project_teardown) @@ -159,7 +159,7 @@ def _append_cleanup(item): items.append(item) def cleanup_scheduled_items(): - wrap_async_to_sync(_cleanup(syn, items), syn) + wrap_async_to_sync(_cleanup(syn, items)) request.addfinalizer(cleanup_scheduled_items) diff --git a/tests/integration/synapseclient/operations/async/__init__.py b/tests/integration/synapseclient/operations/async/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/synapseclient/operations/async/test_factory_operations_async.py b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py new file mode 100644 index 000000000..bc8e04db4 --- /dev/null +++ b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py @@ -0,0 +1,847 @@ +"""Integration tests for the synapseclient.models.factory_operations get_async function.""" + +import tempfile +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.api.table_services import ViewTypeMask +from synapseclient.core import utils +from synapseclient.models import ( + Activity, + Column, + ColumnType, + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + Link, + MaterializedView, + Project, + SubmissionView, + Table, + UsedEntity, + UsedURL, + VirtualTable, +) +from synapseclient.operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, + get_async, +) + + +class TestFactoryOperationsGetAsync: + """Tests for the synapseclient.models.factory_operations.get_async method.""" + + @pytest.fixture(autouse=True, scope="function") + def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: + self.syn = syn + self.schedule_for_cleanup = schedule_for_cleanup + + def create_file_instance(self) -> File: + """Helper method to create a test file.""" + filename = utils.make_bogus_uuid_file() + self.schedule_for_cleanup(filename) + return File( + path=filename, + description="Test file for factory operations", + content_type="text/plain", + name=f"test_file_{str(uuid.uuid4())[:8]}.txt", + ) + + def create_activity(self) -> Activity: + """Helper method to create a test activity.""" + return Activity( + name="Test Activity", + description="Activity for testing factory operations", + used=[ + UsedURL(name="example", url="https://www.synapse.org/"), + UsedEntity(target_id="syn123456", target_version_number=1), + ], + ) + + async def test_get_async_project_by_id(self, project_model: Project) -> None: + """Test retrieving a Project entity by Synapse ID.""" + # GIVEN a project exists + project_id = project_model.id + + # WHEN I retrieve the project using get_async + retrieved_project = await get_async( + synapse_id=project_id, synapse_client=self.syn + ) + + # THEN the correct Project entity is returned + assert isinstance(retrieved_project, Project) + assert retrieved_project.id == project_id + assert retrieved_project.name == project_model.name + assert retrieved_project.description == project_model.description + assert retrieved_project.parent_id is not None + assert retrieved_project.etag is not None + assert retrieved_project.created_on is not None + assert retrieved_project.modified_on is not None + assert retrieved_project.created_by is not None + assert retrieved_project.modified_by is not None + + async def test_get_async_project_by_name(self, project_model: Project) -> None: + """Test retrieving a Project entity by name.""" + # GIVEN a project exists + project_name = project_model.name + + # WHEN I retrieve the project using get_async with entity_name + retrieved_project = await get_async( + entity_name=project_name, parent_id=None, synapse_client=self.syn + ) + + # THEN the correct Project entity is returned + assert isinstance(retrieved_project, Project) + assert retrieved_project.id == project_model.id + assert retrieved_project.name == project_name + + async def test_get_async_folder_by_id(self, project_model: Project) -> None: + """Test retrieving a Folder entity by Synapse ID.""" + # GIVEN a folder in a project + folder = Folder( + name=f"test_folder_{str(uuid.uuid4())[:8]}", + description="Test folder for factory operations", + parent_id=project_model.id, + ) + stored_folder = await folder.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_folder.id) + + # WHEN I retrieve the folder using get_async + retrieved_folder = await get_async( + synapse_id=stored_folder.id, synapse_client=self.syn + ) + + # THEN the correct Folder entity is returned + assert isinstance(retrieved_folder, Folder) + assert retrieved_folder.id == stored_folder.id + assert retrieved_folder.name == stored_folder.name + assert retrieved_folder.description == stored_folder.description + assert retrieved_folder.parent_id == project_model.id + assert retrieved_folder.etag is not None + assert retrieved_folder.created_on is not None + + async def test_get_async_folder_by_name(self, project_model: Project) -> None: + """Test retrieving a Folder entity by name and parent ID.""" + # GIVEN a folder in a project + folder_name = f"test_folder_{str(uuid.uuid4())[:8]}" + folder = Folder( + name=folder_name, + description="Test folder for factory operations", + parent_id=project_model.id, + ) + stored_folder = await folder.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_folder.id) + + # WHEN I retrieve the folder using get_async with entity_name + retrieved_folder = await get_async( + entity_name=folder_name, parent_id=project_model.id, synapse_client=self.syn + ) + + # THEN the correct Folder entity is returned + assert isinstance(retrieved_folder, Folder) + assert retrieved_folder.id == stored_folder.id + assert retrieved_folder.name == folder_name + + async def test_get_async_file_by_id_default_options( + self, project_model: Project + ) -> None: + """Test retrieving a File entity by Synapse ID with default options.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # WHEN I retrieve the file using get_async with default options + retrieved_file = await get_async( + synapse_id=stored_file.id, synapse_client=self.syn + ) + + # THEN the correct File entity is returned with default behavior + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.name == stored_file.name + assert retrieved_file.path is not None # File should be downloaded by default + assert retrieved_file.download_file is True + assert retrieved_file.data_file_handle_id is not None + assert retrieved_file.file_handle is not None + + async def test_get_async_file_by_id_with_file_options( + self, project_model: Project + ) -> None: + """Test retrieving a File entity by Synapse ID with custom FileOptions.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND custom file download options + with tempfile.TemporaryDirectory() as temp_dir: + file_options = FileOptions( + download_file=True, + download_location=temp_dir, + if_collision="overwrite.local", + ) + + # WHEN I retrieve the file using get_async with custom options + retrieved_file = await get_async( + synapse_id=stored_file.id, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the file is retrieved with the specified options + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is True + assert retrieved_file.if_collision == "overwrite.local" + assert utils.normalize_path(temp_dir) in utils.normalize_path( + retrieved_file.path + ) + + async def test_get_async_file_by_id_metadata_only( + self, project_model: Project + ) -> None: + """Test retrieving a File entity metadata without downloading.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND file options to skip download + file_options = FileOptions(download_file=False) + + # WHEN I retrieve the file using get_async without downloading + retrieved_file = await get_async( + synapse_id=stored_file.id, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the file metadata is retrieved without download + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is False + assert retrieved_file.data_file_handle_id is not None + + async def test_get_async_file_by_id_with_activity( + self, project_model: Project + ) -> None: + """Test retrieving a File entity with activity information.""" + # GIVEN a file with activity in a project + file = self.create_file_instance() + file.parent_id = project_model.id + file.activity = self.create_activity() + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND activity options to include activity + activity_options = ActivityOptions(include_activity=True) + + # WHEN I retrieve the file using get_async with activity options + retrieved_file = await get_async( + synapse_id=stored_file.id, + activity_options=activity_options, + synapse_client=self.syn, + ) + + # THEN the file is retrieved with activity information + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.activity is not None + assert retrieved_file.activity.name == "Test Activity" + assert ( + retrieved_file.activity.description + == "Activity for testing factory operations" + ) + + async def test_get_async_file_by_id_specific_version( + self, project_model: Project + ) -> None: + """Test retrieving a specific version of a File entity.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + file.version_comment = "Version 1" + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND I update the file to create version 2 + file.version_comment = "Version 2" + await file.store_async(synapse_client=self.syn) + + # WHEN I retrieve version 1 specifically + retrieved_file = await get_async( + synapse_id=stored_file.id, version_number=1, synapse_client=self.syn + ) + + # THEN version 1 is returned + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.version_number == 1 + assert retrieved_file.version_comment == "Version 1" + + async def test_get_async_table_by_id_default_options( + self, project_model: Project + ) -> None: + """Test retrieving a Table entity by Synapse ID with default options.""" + # GIVEN a table in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + ) + stored_table = await table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # WHEN I retrieve the table using get_async + retrieved_table = await get_async( + synapse_id=stored_table.id, synapse_client=self.syn + ) + + # THEN the correct Table entity is returned with columns + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert retrieved_table.name == stored_table.name + assert len(retrieved_table.columns) == 2 + assert any(col.name == "col1" for col in retrieved_table.columns.values()) + assert any(col.name == "col2" for col in retrieved_table.columns.values()) + + async def test_get_async_table_by_id_with_table_options( + self, project_model: Project + ) -> None: + """Test retrieving a Table entity with custom TableOptions.""" + # GIVEN a table in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + ) + stored_table = await table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # AND table options to exclude columns + table_options = TableOptions(include_columns=False) + + # WHEN I retrieve the table using get_async without columns + retrieved_table = await get_async( + synapse_id=stored_table.id, + table_options=table_options, + synapse_client=self.syn, + ) + + # THEN the table is retrieved without column information + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert len(retrieved_table.columns) == 0 + + async def test_get_async_table_by_id_with_activity( + self, project_model: Project + ) -> None: + """Test retrieving a Table entity with activity information.""" + # GIVEN a table with activity in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + activity=self.create_activity(), + ) + stored_table = await table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # AND activity options to include activity + activity_options = ActivityOptions(include_activity=True) + + # WHEN I retrieve the table using get_async with activity options + retrieved_table = await get_async( + synapse_id=stored_table.id, + activity_options=activity_options, + synapse_client=self.syn, + ) + + # THEN the table is retrieved with activity information + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert retrieved_table.activity is not None + assert retrieved_table.activity.name == "Test Activity" + + async def test_get_async_dataset_by_id(self, project_model: Project) -> None: + """Test retrieving a Dataset entity by Synapse ID.""" + # GIVEN a dataset in a project + columns = [ + Column(name="itemId", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + dataset = Dataset( + name=f"test_dataset_{str(uuid.uuid4())[:8]}", + description="Test dataset for factory operations", + parent_id=project_model.id, + columns=columns, + include_default_columns=False, + ) + stored_dataset = await dataset.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_dataset.id) + + # WHEN I retrieve the dataset using get_async + retrieved_dataset = await get_async( + synapse_id=stored_dataset.id, synapse_client=self.syn + ) + + # THEN the correct Dataset entity is returned + assert isinstance(retrieved_dataset, Dataset) + assert retrieved_dataset.id == stored_dataset.id + assert retrieved_dataset.name == stored_dataset.name + assert len(retrieved_dataset.columns) == 2 + + async def test_get_async_dataset_collection_by_id( + self, project_model: Project + ) -> None: + """Test retrieving a DatasetCollection entity by Synapse ID.""" + # GIVEN a dataset collection in a project + dataset_collection = DatasetCollection( + name=f"test_dataset_collection_{str(uuid.uuid4())[:8]}", + description="Test dataset collection for factory operations", + parent_id=project_model.id, + include_default_columns=False, + ) + stored_collection = await dataset_collection.store_async( + synapse_client=self.syn + ) + self.schedule_for_cleanup(stored_collection.id) + + # WHEN I retrieve the dataset collection using get_async + retrieved_collection = await get_async( + synapse_id=stored_collection.id, synapse_client=self.syn + ) + + # THEN the correct DatasetCollection entity is returned + assert isinstance(retrieved_collection, DatasetCollection) + assert retrieved_collection.id == stored_collection.id + assert retrieved_collection.name == stored_collection.name + + async def test_get_async_entity_view_by_id(self, project_model: Project) -> None: + """Test retrieving an EntityView entity by Synapse ID.""" + # GIVEN an entity view in a project + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + entity_view = EntityView( + name=f"test_entity_view_{str(uuid.uuid4())[:8]}", + description="Test entity view for factory operations", + parent_id=project_model.id, + columns=columns, + scope_ids=[project_model.id], + view_type_mask=ViewTypeMask.FILE, + include_default_columns=False, + ) + stored_view = await entity_view.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the entity view using get_async + retrieved_view = await get_async( + synapse_id=stored_view.id, synapse_client=self.syn + ) + + # THEN the correct EntityView entity is returned + assert isinstance(retrieved_view, EntityView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + assert len(retrieved_view.columns) >= 2 # May include default columns + + async def test_get_async_submission_view_by_id( + self, project_model: Project + ) -> None: + """Test retrieving a SubmissionView entity by Synapse ID.""" + # GIVEN a submission view in a project + columns = [ + Column(name="id", column_type=ColumnType.SUBMISSIONID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + submission_view = SubmissionView( + name=f"test_submission_view_{str(uuid.uuid4())[:8]}", + description="Test submission view for factory operations", + parent_id=project_model.id, + columns=columns, + scope_ids=[project_model.id], + include_default_columns=False, + ) + stored_view = await submission_view.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the submission view using get_async + retrieved_view = await get_async( + synapse_id=stored_view.id, synapse_client=self.syn + ) + + # THEN the correct SubmissionView entity is returned + assert isinstance(retrieved_view, SubmissionView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + + async def test_get_async_materialized_view_by_id( + self, project_model: Project + ) -> None: + """Test retrieving a MaterializedView entity by Synapse ID.""" + # GIVEN a simple table to create materialized view from + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + source_table = Table( + name=f"source_table_{str(uuid.uuid4())[:8]}", + parent_id=project_model.id, + columns=columns, + ) + stored_source = await source_table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_source.id) + + # AND a materialized view + materialized_view = MaterializedView( + name=f"test_materialized_view_{str(uuid.uuid4())[:8]}", + description="Test materialized view for factory operations", + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {stored_source.id}", + ) + stored_view = await materialized_view.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the materialized view using get_async + retrieved_view = await get_async( + synapse_id=stored_view.id, synapse_client=self.syn + ) + + # THEN the correct MaterializedView entity is returned + assert isinstance(retrieved_view, MaterializedView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + assert retrieved_view.defining_sql is not None + + async def test_get_async_virtual_table_by_id(self, project_model: Project) -> None: + """Test retrieving a VirtualTable entity by Synapse ID.""" + # GIVEN a simple table to create virtual table from + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + source_table = Table( + name=f"source_table_{str(uuid.uuid4())[:8]}", + parent_id=project_model.id, + columns=columns, + ) + stored_source = await source_table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_source.id) + + # AND a virtual table + virtual_table = VirtualTable( + name=f"test_virtual_table_{str(uuid.uuid4())[:8]}", + description="Test virtual table for factory operations", + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {stored_source.id}", + ) + stored_virtual = await virtual_table.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_virtual.id) + + # WHEN I retrieve the virtual table using get_async + retrieved_virtual = await get_async( + synapse_id=stored_virtual.id, synapse_client=self.syn + ) + + # THEN the correct VirtualTable entity is returned + assert isinstance(retrieved_virtual, VirtualTable) + assert retrieved_virtual.id == stored_virtual.id + assert retrieved_virtual.name == stored_virtual.name + assert retrieved_virtual.defining_sql is not None + + async def test_get_async_link_by_id_without_following( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID without following the link.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = await link.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND link options to not follow the link + link_options = LinkOptions(follow_link=False) + + # WHEN I retrieve the link using get_async without following + retrieved_link = await get_async( + synapse_id=stored_link.id, + link_options=link_options, + synapse_client=self.syn, + ) + + # THEN the Link entity itself is returned + assert isinstance(retrieved_link, Link) + assert retrieved_link.id == stored_link.id + assert retrieved_link.name == stored_link.name + assert retrieved_link.target_id == stored_file.id + + async def test_get_async_link_by_id_default_follows_link( + self, project_model: Project + ) -> None: + """Test that getting a Link by ID follows the link by default (no LinkOptions provided).""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = await link.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # WHEN I retrieve the link without any options (should use defaults) + retrieved_entity = await get_async( + synapse_id=stored_link.id, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned (default follow_link=True behavior) + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_async_link_by_id_with_following( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID and following to the target (default behavior).""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = await link.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # WHEN I retrieve the link using get_async with default behavior (follow_link=True) + retrieved_entity = await get_async( + synapse_id=stored_link.id, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned instead of the Link (default behavior is now follow_link=True) + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_async_link_by_id_with_following_explicit( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID with explicit follow_link=True.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = await link.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND link options to follow the link + link_options = LinkOptions(follow_link=True) + + # WHEN I retrieve the link using get_async with following + retrieved_entity = await get_async( + synapse_id=stored_link.id, + link_options=link_options, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned instead of the Link + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_async_link_by_id_with_file_options( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity that points to a File with custom file options.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = await link.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND custom file download options and link options + with tempfile.TemporaryDirectory() as temp_dir: + file_options = FileOptions( + download_file=True, + download_location=temp_dir, + if_collision="overwrite.local", + ) + link_options = LinkOptions(follow_link=True) + + # WHEN I retrieve the link using get_async with both link and file options + retrieved_entity = await get_async( + synapse_id=stored_link.id, + link_options=link_options, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned with the custom options applied + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + assert utils.normalize_path(temp_dir) in utils.normalize_path( + retrieved_entity.path + ) + assert retrieved_entity.download_file is True + + async def test_get_async_with_entity_instance(self, project_model: Project) -> None: + """Test get_async when passing an entity instance directly.""" + # GIVEN an existing File entity instance + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND file options to change behavior + file_options = FileOptions(download_file=False) + + # WHEN I pass the entity instance to get_async with new options + refreshed_file = await get_async( + stored_file, file_options=file_options, synapse_client=self.syn + ) + + # THEN the entity is refreshed with the new options applied + assert isinstance(refreshed_file, File) + assert refreshed_file.id == stored_file.id + assert refreshed_file.download_file is False + + async def test_get_async_combined_options(self, project_model: Project) -> None: + """Test get_async with multiple option types combined.""" + # GIVEN a file with activity + file = self.create_file_instance() + file.parent_id = project_model.id + file.activity = self.create_activity() + stored_file = await file.store_async(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND combined options + activity_options = ActivityOptions(include_activity=True) + file_options = FileOptions(download_file=False) + + # WHEN I retrieve the file with combined options + retrieved_file = await get_async( + synapse_id=stored_file.id, + activity_options=activity_options, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN both options are applied + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is False + assert retrieved_file.activity is not None + assert retrieved_file.activity.name == "Test Activity" + + async def test_get_async_invalid_synapse_id_raises_error(self) -> None: + """Test that get_async raises appropriate error for invalid Synapse ID.""" + # GIVEN an invalid synapse ID + invalid_id = "syn999999999999" + + # WHEN I try to retrieve the entity + # THEN an appropriate error is raised + with pytest.raises(Exception): # Could be SynapseNotFoundError or similar + await get_async(synapse_id=invalid_id, synapse_client=self.syn) + + async def test_get_async_invalid_entity_name_raises_error( + self, project_model: Project + ) -> None: + """Test that get_async raises appropriate error for invalid entity name.""" + # GIVEN an invalid entity name + invalid_name = f"nonexistent_entity_{str(uuid.uuid4())}" + + # WHEN I try to retrieve the entity by name + # THEN an appropriate error is raised + with pytest.raises(Exception): # Could be SynapseNotFoundError or similar + await get_async( + entity_name=invalid_name, + parent_id=project_model.id, + synapse_client=self.syn, + ) + + async def test_get_async_validation_errors(self) -> None: + """Test validation errors for invalid parameter combinations.""" + # WHEN I provide both synapse_id and entity_name + # THEN ValueError is raised + with pytest.raises( + ValueError, match="Cannot specify both synapse_id and entity_name" + ): + await get_async( + synapse_id="syn123456", + entity_name="test_entity", + synapse_client=self.syn, + ) + + # WHEN I provide neither synapse_id nor entity_name + # THEN ValueError is raised + with pytest.raises( + ValueError, match="Must specify either synapse_id or entity_name" + ): + await get_async(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/operations/synchronous/__init__.py b/tests/integration/synapseclient/operations/synchronous/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py new file mode 100644 index 000000000..9da882a38 --- /dev/null +++ b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py @@ -0,0 +1,811 @@ +"""Integration tests for the synapseclient.models.factory_operations get function.""" + +import tempfile +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.api.table_services import ViewTypeMask +from synapseclient.core import utils +from synapseclient.models import ( + Activity, + Column, + ColumnType, + Dataset, + DatasetCollection, + EntityView, + File, + Folder, + Link, + MaterializedView, + Project, + SubmissionView, + Table, + UsedEntity, + UsedURL, + VirtualTable, +) +from synapseclient.operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, + get, +) + + +class TestFactoryOperationsGetAsync: + """Tests for the synapseclient.models.factory_operations.get method.""" + + @pytest.fixture(autouse=True, scope="function") + def init(self, syn: Synapse, schedule_for_cleanup: Callable[..., None]) -> None: + self.syn = syn + self.schedule_for_cleanup = schedule_for_cleanup + + def create_file_instance(self) -> File: + """Helper method to create a test file.""" + filename = utils.make_bogus_uuid_file() + self.schedule_for_cleanup(filename) + return File( + path=filename, + description="Test file for factory operations", + content_type="text/plain", + name=f"test_file_{str(uuid.uuid4())[:8]}.txt", + ) + + def create_activity(self) -> Activity: + """Helper method to create a test activity.""" + return Activity( + name="Test Activity", + description="Activity for testing factory operations", + used=[ + UsedURL(name="example", url="https://www.synapse.org/"), + UsedEntity(target_id="syn123456", target_version_number=1), + ], + ) + + async def test_get_project_by_id(self, project_model: Project) -> None: + """Test retrieving a Project entity by Synapse ID.""" + # GIVEN a project exists + project_id = project_model.id + + # WHEN I retrieve the project using get + retrieved_project = get(synapse_id=project_id, synapse_client=self.syn) + + # THEN the correct Project entity is returned + assert isinstance(retrieved_project, Project) + assert retrieved_project.id == project_id + assert retrieved_project.name == project_model.name + assert retrieved_project.description == project_model.description + assert retrieved_project.parent_id is not None + assert retrieved_project.etag is not None + assert retrieved_project.created_on is not None + assert retrieved_project.modified_on is not None + assert retrieved_project.created_by is not None + assert retrieved_project.modified_by is not None + + async def test_get_project_by_name(self, project_model: Project) -> None: + """Test retrieving a Project entity by name.""" + # GIVEN a project exists + project_name = project_model.name + + # WHEN I retrieve the project using get with entity_name + retrieved_project = get( + entity_name=project_name, parent_id=None, synapse_client=self.syn + ) + + # THEN the correct Project entity is returned + assert isinstance(retrieved_project, Project) + assert retrieved_project.id == project_model.id + assert retrieved_project.name == project_name + + async def test_get_folder_by_id(self, project_model: Project) -> None: + """Test retrieving a Folder entity by Synapse ID.""" + # GIVEN a folder in a project + folder = Folder( + name=f"test_folder_{str(uuid.uuid4())[:8]}", + description="Test folder for factory operations", + parent_id=project_model.id, + ) + stored_folder = folder.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_folder.id) + + # WHEN I retrieve the folder using get + retrieved_folder = get(synapse_id=stored_folder.id, synapse_client=self.syn) + + # THEN the correct Folder entity is returned + assert isinstance(retrieved_folder, Folder) + assert retrieved_folder.id == stored_folder.id + assert retrieved_folder.name == stored_folder.name + assert retrieved_folder.description == stored_folder.description + assert retrieved_folder.parent_id == project_model.id + assert retrieved_folder.etag is not None + assert retrieved_folder.created_on is not None + + async def test_get_folder_by_name(self, project_model: Project) -> None: + """Test retrieving a Folder entity by name and parent ID.""" + # GIVEN a folder in a project + folder_name = f"test_folder_{str(uuid.uuid4())[:8]}" + folder = Folder( + name=folder_name, + description="Test folder for factory operations", + parent_id=project_model.id, + ) + stored_folder = folder.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_folder.id) + + # WHEN I retrieve the folder using get with entity_name + retrieved_folder = get( + entity_name=folder_name, parent_id=project_model.id, synapse_client=self.syn + ) + + # THEN the correct Folder entity is returned + assert isinstance(retrieved_folder, Folder) + assert retrieved_folder.id == stored_folder.id + assert retrieved_folder.name == folder_name + + async def test_get_file_by_id_default_options(self, project_model: Project) -> None: + """Test retrieving a File entity by Synapse ID with default options.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # WHEN I retrieve the file using get with default options + retrieved_file = get(synapse_id=stored_file.id, synapse_client=self.syn) + + # THEN the correct File entity is returned with default behavior + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.name == stored_file.name + assert retrieved_file.path is not None # File should be downloaded by default + assert retrieved_file.download_file is True + assert retrieved_file.data_file_handle_id is not None + assert retrieved_file.file_handle is not None + + async def test_get_file_by_id_with_file_options( + self, project_model: Project + ) -> None: + """Test retrieving a File entity by Synapse ID with custom FileOptions.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND custom file download options + with tempfile.TemporaryDirectory() as temp_dir: + file_options = FileOptions( + download_file=True, + download_location=temp_dir, + if_collision="overwrite.local", + ) + + # WHEN I retrieve the file using get with custom options + retrieved_file = get( + synapse_id=stored_file.id, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the file is retrieved with the specified options + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is True + assert retrieved_file.if_collision == "overwrite.local" + assert utils.normalize_path(temp_dir) in utils.normalize_path( + retrieved_file.path + ) + + async def test_get_file_by_id_metadata_only(self, project_model: Project) -> None: + """Test retrieving a File entity metadata without downloading.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND file options to skip download + file_options = FileOptions(download_file=False) + + # WHEN I retrieve the file using get without downloading + retrieved_file = get( + synapse_id=stored_file.id, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the file metadata is retrieved without download + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is False + assert retrieved_file.data_file_handle_id is not None + + async def test_get_file_by_id_with_activity(self, project_model: Project) -> None: + """Test retrieving a File entity with activity information.""" + # GIVEN a file with activity in a project + file = self.create_file_instance() + file.parent_id = project_model.id + file.activity = self.create_activity() + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND activity options to include activity + activity_options = ActivityOptions(include_activity=True) + + # WHEN I retrieve the file using get with activity options + retrieved_file = get( + synapse_id=stored_file.id, + activity_options=activity_options, + synapse_client=self.syn, + ) + + # THEN the file is retrieved with activity information + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.activity is not None + assert retrieved_file.activity.name == "Test Activity" + assert ( + retrieved_file.activity.description + == "Activity for testing factory operations" + ) + + async def test_get_file_by_id_specific_version( + self, project_model: Project + ) -> None: + """Test retrieving a specific version of a File entity.""" + # GIVEN a file in a project + file = self.create_file_instance() + file.parent_id = project_model.id + file.version_comment = "Version 1" + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND I update the file to create version 2 + file.version_comment = "Version 2" + file.store(synapse_client=self.syn) + + # WHEN I retrieve version 1 specifically + retrieved_file = get( + synapse_id=stored_file.id, version_number=1, synapse_client=self.syn + ) + + # THEN version 1 is returned + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.version_number == 1 + assert retrieved_file.version_comment == "Version 1" + + async def test_get_table_by_id_default_options( + self, project_model: Project + ) -> None: + """Test retrieving a Table entity by Synapse ID with default options.""" + # GIVEN a table in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + ) + stored_table = table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # WHEN I retrieve the table using get + retrieved_table = get(synapse_id=stored_table.id, synapse_client=self.syn) + + # THEN the correct Table entity is returned with columns + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert retrieved_table.name == stored_table.name + assert len(retrieved_table.columns) == 2 + assert any(col.name == "col1" for col in retrieved_table.columns.values()) + assert any(col.name == "col2" for col in retrieved_table.columns.values()) + + async def test_get_table_by_id_with_table_options( + self, project_model: Project + ) -> None: + """Test retrieving a Table entity with custom TableOptions.""" + # GIVEN a table in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + ) + stored_table = table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # AND table options to exclude columns + table_options = TableOptions(include_columns=False) + + # WHEN I retrieve the table using get without columns + retrieved_table = get( + synapse_id=stored_table.id, + table_options=table_options, + synapse_client=self.syn, + ) + + # THEN the table is retrieved without column information + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert len(retrieved_table.columns) == 0 + + async def test_get_table_by_id_with_activity(self, project_model: Project) -> None: + """Test retrieving a Table entity with activity information.""" + # GIVEN a table with activity in a project + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + ] + table = Table( + name=f"test_table_{str(uuid.uuid4())[:8]}", + description="Test table for factory operations", + parent_id=project_model.id, + columns=columns, + activity=self.create_activity(), + ) + stored_table = table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_table.id) + + # AND activity options to include activity + activity_options = ActivityOptions(include_activity=True) + + # WHEN I retrieve the table using get with activity options + retrieved_table = get( + synapse_id=stored_table.id, + activity_options=activity_options, + synapse_client=self.syn, + ) + + # THEN the table is retrieved with activity information + assert isinstance(retrieved_table, Table) + assert retrieved_table.id == stored_table.id + assert retrieved_table.activity is not None + assert retrieved_table.activity.name == "Test Activity" + + async def test_get_dataset_by_id(self, project_model: Project) -> None: + """Test retrieving a Dataset entity by Synapse ID.""" + # GIVEN a dataset in a project + columns = [ + Column(name="itemId", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + dataset = Dataset( + name=f"test_dataset_{str(uuid.uuid4())[:8]}", + description="Test dataset for factory operations", + parent_id=project_model.id, + columns=columns, + include_default_columns=False, + ) + stored_dataset = dataset.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_dataset.id) + + # WHEN I retrieve the dataset using get + retrieved_dataset = get(synapse_id=stored_dataset.id, synapse_client=self.syn) + + # THEN the correct Dataset entity is returned + assert isinstance(retrieved_dataset, Dataset) + assert retrieved_dataset.id == stored_dataset.id + assert retrieved_dataset.name == stored_dataset.name + assert len(retrieved_dataset.columns) == 2 + + async def test_get_dataset_collection_by_id(self, project_model: Project) -> None: + """Test retrieving a DatasetCollection entity by Synapse ID.""" + # GIVEN a dataset collection in a project + dataset_collection = DatasetCollection( + name=f"test_dataset_collection_{str(uuid.uuid4())[:8]}", + description="Test dataset collection for factory operations", + parent_id=project_model.id, + include_default_columns=False, + ) + stored_collection = dataset_collection.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_collection.id) + + # WHEN I retrieve the dataset collection using get + retrieved_collection = get( + synapse_id=stored_collection.id, synapse_client=self.syn + ) + + # THEN the correct DatasetCollection entity is returned + assert isinstance(retrieved_collection, DatasetCollection) + assert retrieved_collection.id == stored_collection.id + assert retrieved_collection.name == stored_collection.name + + async def test_get_entity_view_by_id(self, project_model: Project) -> None: + """Test retrieving an EntityView entity by Synapse ID.""" + # GIVEN an entity view in a project + columns = [ + Column(name="id", column_type=ColumnType.ENTITYID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + entity_view = EntityView( + name=f"test_entity_view_{str(uuid.uuid4())[:8]}", + description="Test entity view for factory operations", + parent_id=project_model.id, + columns=columns, + scope_ids=[project_model.id], + view_type_mask=ViewTypeMask.FILE, + include_default_columns=False, + ) + stored_view = entity_view.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the entity view using get + retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) + + # THEN the correct EntityView entity is returned + assert isinstance(retrieved_view, EntityView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + assert len(retrieved_view.columns) >= 2 # May include default columns + + async def test_get_submission_view_by_id(self, project_model: Project) -> None: + """Test retrieving a SubmissionView entity by Synapse ID.""" + # GIVEN a submission view in a project + columns = [ + Column(name="id", column_type=ColumnType.SUBMISSIONID), + Column(name="name", column_type=ColumnType.STRING, maximum_size=256), + ] + submission_view = SubmissionView( + name=f"test_submission_view_{str(uuid.uuid4())[:8]}", + description="Test submission view for factory operations", + parent_id=project_model.id, + columns=columns, + scope_ids=[project_model.id], + include_default_columns=False, + ) + stored_view = submission_view.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the submission view using get + retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) + + # THEN the correct SubmissionView entity is returned + assert isinstance(retrieved_view, SubmissionView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + + async def test_get_materialized_view_by_id(self, project_model: Project) -> None: + """Test retrieving a MaterializedView entity by Synapse ID.""" + # GIVEN a simple table to create materialized view from + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + source_table = Table( + name=f"source_table_{str(uuid.uuid4())[:8]}", + parent_id=project_model.id, + columns=columns, + ) + stored_source = source_table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_source.id) + + # AND a materialized view + materialized_view = MaterializedView( + name=f"test_materialized_view_{str(uuid.uuid4())[:8]}", + description="Test materialized view for factory operations", + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {stored_source.id}", + ) + stored_view = materialized_view.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_view.id) + + # WHEN I retrieve the materialized view using get + retrieved_view = get(synapse_id=stored_view.id, synapse_client=self.syn) + + # THEN the correct MaterializedView entity is returned + assert isinstance(retrieved_view, MaterializedView) + assert retrieved_view.id == stored_view.id + assert retrieved_view.name == stored_view.name + assert retrieved_view.defining_sql is not None + + async def test_get_virtual_table_by_id(self, project_model: Project) -> None: + """Test retrieving a VirtualTable entity by Synapse ID.""" + # GIVEN a simple table to create virtual table from + columns = [ + Column(name="col1", column_type=ColumnType.STRING, maximum_size=50), + Column(name="col2", column_type=ColumnType.INTEGER), + ] + source_table = Table( + name=f"source_table_{str(uuid.uuid4())[:8]}", + parent_id=project_model.id, + columns=columns, + ) + stored_source = source_table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_source.id) + + # AND a virtual table + virtual_table = VirtualTable( + name=f"test_virtual_table_{str(uuid.uuid4())[:8]}", + description="Test virtual table for factory operations", + parent_id=project_model.id, + defining_sql=f"SELECT * FROM {stored_source.id}", + ) + stored_virtual = virtual_table.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_virtual.id) + + # WHEN I retrieve the virtual table using get + retrieved_virtual = get(synapse_id=stored_virtual.id, synapse_client=self.syn) + + # THEN the correct VirtualTable entity is returned + assert isinstance(retrieved_virtual, VirtualTable) + assert retrieved_virtual.id == stored_virtual.id + assert retrieved_virtual.name == stored_virtual.name + assert retrieved_virtual.defining_sql is not None + + async def test_get_link_by_id_without_following( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID without following the link.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = link.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND link options to not follow the link + link_options = LinkOptions(follow_link=False) + + # WHEN I retrieve the link using get without following + retrieved_link = get( + synapse_id=stored_link.id, + link_options=link_options, + synapse_client=self.syn, + ) + + # THEN the Link entity itself is returned + assert isinstance(retrieved_link, Link) + assert retrieved_link.id == stored_link.id + assert retrieved_link.name == stored_link.name + assert retrieved_link.target_id == stored_file.id + + async def test_get_link_by_id_default_follows_link( + self, project_model: Project + ) -> None: + """Test that getting a Link by ID follows the link by default (no LinkOptions provided).""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = link.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # WHEN I retrieve the link without any options (should use defaults) + retrieved_entity = get( + synapse_id=stored_link.id, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned (default follow_link=True behavior) + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_link_by_id_with_following(self, project_model: Project) -> None: + """Test retrieving a Link entity by Synapse ID and following to the target (default behavior).""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = link.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # WHEN I retrieve the link using get with default behavior (follow_link=True) + retrieved_entity = get( + synapse_id=stored_link.id, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned instead of the Link (default behavior is now follow_link=True) + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_link_by_id_with_following_explicit( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID with explicit follow_link=True.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = link.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND link options to follow the link + link_options = LinkOptions(follow_link=True) + + # WHEN I retrieve the link using get with following + retrieved_entity = get( + synapse_id=stored_link.id, + link_options=link_options, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned instead of the Link + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + + async def test_get_link_by_id_with_file_options( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity that points to a File with custom file options.""" + # GIVEN a file and a link to that file + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + link = Link( + name=f"test_link_{str(uuid.uuid4())[:8]}", + description="Test link for factory operations", + parent_id=project_model.id, + target_id=stored_file.id, + ) + stored_link = link.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_link.id) + + # AND custom file download options and link options + with tempfile.TemporaryDirectory() as temp_dir: + file_options = FileOptions( + download_file=True, + download_location=temp_dir, + if_collision="overwrite.local", + ) + link_options = LinkOptions(follow_link=True) + + # WHEN I retrieve the link using get with both link and file options + retrieved_entity = get( + synapse_id=stored_link.id, + link_options=link_options, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN the target File entity is returned with the custom options applied + assert isinstance(retrieved_entity, File) + assert retrieved_entity.id == stored_file.id + assert retrieved_entity.name == stored_file.name + assert utils.normalize_path(temp_dir) in utils.normalize_path( + retrieved_entity.path + ) + assert retrieved_entity.download_file is True + + async def test_get_with_entity_instance(self, project_model: Project) -> None: + """Test get when passing an entity instance directly.""" + # GIVEN an existing File entity instance + file = self.create_file_instance() + file.parent_id = project_model.id + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND file options to change behavior + file_options = FileOptions(download_file=False) + + # WHEN I pass the entity instance to get with new options + refreshed_file = get( + stored_file, file_options=file_options, synapse_client=self.syn + ) + + # THEN the entity is refreshed with the new options applied + assert isinstance(refreshed_file, File) + assert refreshed_file.id == stored_file.id + assert refreshed_file.download_file is False + + async def test_get_combined_options(self, project_model: Project) -> None: + """Test get with multiple option types combined.""" + # GIVEN a file with activity + file = self.create_file_instance() + file.parent_id = project_model.id + file.activity = self.create_activity() + stored_file = file.store(synapse_client=self.syn) + self.schedule_for_cleanup(stored_file.id) + + # AND combined options + activity_options = ActivityOptions(include_activity=True) + file_options = FileOptions(download_file=False) + + # WHEN I retrieve the file with combined options + retrieved_file = get( + synapse_id=stored_file.id, + activity_options=activity_options, + file_options=file_options, + synapse_client=self.syn, + ) + + # THEN both options are applied + assert isinstance(retrieved_file, File) + assert retrieved_file.id == stored_file.id + assert retrieved_file.download_file is False + assert retrieved_file.activity is not None + assert retrieved_file.activity.name == "Test Activity" + + async def test_get_invalid_synapse_id_raises_error(self) -> None: + """Test that get raises appropriate error for invalid Synapse ID.""" + # GIVEN an invalid synapse ID + invalid_id = "syn999999999999" + + # WHEN I try to retrieve the entity + # THEN an appropriate error is raised + with pytest.raises(Exception): # Could be SynapseNotFoundError or similar + get(synapse_id=invalid_id, synapse_client=self.syn) + + async def test_get_invalid_entity_name_raises_error( + self, project_model: Project + ) -> None: + """Test that get raises appropriate error for invalid entity name.""" + # GIVEN an invalid entity name + invalid_name = f"nonexistent_entity_{str(uuid.uuid4())}" + + # WHEN I try to retrieve the entity by name + # THEN an appropriate error is raised + with pytest.raises(Exception): # Could be SynapseNotFoundError or similar + get( + entity_name=invalid_name, + parent_id=project_model.id, + synapse_client=self.syn, + ) + + async def test_get_validation_errors(self) -> None: + """Test validation errors for invalid parameter combinations.""" + # WHEN I provide both synapse_id and entity_name + # THEN ValueError is raised + with pytest.raises( + ValueError, match="Cannot specify both synapse_id and entity_name" + ): + get( + synapse_id="syn123456", + entity_name="test_entity", + synapse_client=self.syn, + ) + + # WHEN I provide neither synapse_id nor entity_name + # THEN ValueError is raised + with pytest.raises( + ValueError, match="Must specify either synapse_id or entity_name" + ): + get(synapse_client=self.syn) diff --git a/tests/unit/synapseclient/models/async/unit_test_folder_async.py b/tests/unit/synapseclient/models/async/unit_test_folder_async.py index 8c3feb574..d096d5a07 100644 --- a/tests/unit/synapseclient/models/async/unit_test_folder_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_folder_async.py @@ -89,9 +89,9 @@ async def test_store_with_id(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -108,15 +108,18 @@ async def test_store_with_id(self) -> None: result = await folder.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -244,9 +247,9 @@ async def test_store_after_get_with_changes(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_store, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -255,15 +258,18 @@ async def test_store_after_get_with_changes(self) -> None: result = await folder.store_async(synapse_client=self.syn) # THEN we should call store because there are changes - mocked_store.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_store.assert_called_once() + call_args = mocked_store.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should not call get as we already have mocked_get.assert_not_called() @@ -300,9 +306,9 @@ async def test_store_with_annotations(self) -> None: with patch( "synapseclient.models.folder.store_entity_components", return_value=(None), - ) as mocked_store_entity_components, patch.object( - self.syn, - "store", + ) as mocked_store_entity_components, patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -319,15 +325,18 @@ async def test_store_with_annotations(self) -> None: result = await folder.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -362,9 +371,9 @@ async def test_store_with_name_and_parent_id(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch.object( self.syn, @@ -385,17 +394,19 @@ async def test_store_with_name_and_parent_id(self) -> None: result = await folder.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - name=folder.name, - parent=folder.parent_id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == SYN_123 # From findEntityId mock + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["name"] == folder.name + assert request_dict["parentId"] == folder.parent_id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -425,9 +436,9 @@ async def test_store_with_name_and_parent(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch.object( self.syn, @@ -450,17 +461,19 @@ async def test_store_with_name_and_parent(self) -> None: ) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - name=folder.name, - parent=folder.parent_id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == SYN_123 # From findEntityId mock + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["name"] == folder.name + assert request_dict["parentId"] == PARENT_ID + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() diff --git a/tests/unit/synapseclient/models/async/unit_test_project_async.py b/tests/unit/synapseclient/models/async/unit_test_project_async.py index 8839caf3b..c7e41fe80 100644 --- a/tests/unit/synapseclient/models/async/unit_test_project_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_project_async.py @@ -88,9 +88,9 @@ async def test_store_with_id(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -107,14 +107,18 @@ async def test_store_with_id(self) -> None: result = await project.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == project.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["id"] == project.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -247,9 +251,9 @@ async def test_store_after_get_with_changes(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_store, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -257,14 +261,18 @@ async def test_store_after_get_with_changes(self) -> None: result = await project.store_async(synapse_client=self.syn) # THEN we should call store because there are changes - mocked_store.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_store.assert_called_once() + call_args = mocked_store.call_args + assert call_args.kwargs["entity_id"] == project.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["id"] == project.id + assert request_dict["description"] == description # AND we should not call get as we already have mocked_get.assert_not_called() @@ -301,9 +309,9 @@ async def test_store_with_annotations(self) -> None: with patch( "synapseclient.models.project.store_entity_components", return_value=(None), - ) as mocked_store_entity_components, patch.object( - self.syn, - "store", + ) as mocked_store_entity_components, patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -320,14 +328,18 @@ async def test_store_with_annotations(self) -> None: result = await project.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == project.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["id"] == project.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -362,9 +374,9 @@ async def test_store_with_name_and_parent_id(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch.object( self.syn, @@ -385,16 +397,18 @@ async def test_store_with_name_and_parent_id(self) -> None: result = await project.store_async(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - name=project.name, - parent=project.parent_id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == PROJECT_ID # From findEntityId mock + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["name"] == project.name + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_folder.py b/tests/unit/synapseclient/models/synchronous/unit_test_folder.py index 3f9cb4bc4..bcefaf545 100644 --- a/tests/unit/synapseclient/models/synchronous/unit_test_folder.py +++ b/tests/unit/synapseclient/models/synchronous/unit_test_folder.py @@ -89,9 +89,9 @@ def test_store_with_id(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -108,15 +108,18 @@ def test_store_with_id(self) -> None: result = folder.store(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -244,9 +247,9 @@ def test_store_after_get_with_changes(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_store, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -255,15 +258,18 @@ def test_store_after_get_with_changes(self) -> None: result = folder.store(synapse_client=self.syn) # THEN we should call store because there are changes - mocked_store.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_store.assert_called_once() + call_args = mocked_store.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should not call get as we already have mocked_get.assert_not_called() @@ -300,9 +306,9 @@ def test_store_with_annotations(self) -> None: with patch( "synapseclient.models.folder.store_entity_components", return_value=(None), - ) as mocked_store_entity_components, patch.object( - self.syn, - "store", + ) as mocked_store_entity_components, patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -319,15 +325,18 @@ def test_store_with_annotations(self) -> None: result = folder.store(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == folder.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["id"] == folder.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -362,9 +371,9 @@ def test_store_with_name_and_parent_id(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch.object( self.syn, @@ -385,17 +394,16 @@ def test_store_with_name_and_parent_id(self) -> None: result = folder.store() # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - name=folder.name, - parent=folder.parent_id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, - ) + mocked_client_call.assert_called_once() + assert mocked_client_call.call_args.kwargs["entity_id"] == SYN_123 + assert mocked_client_call.call_args.kwargs["new_version"] is False + assert mocked_client_call.call_args.kwargs["synapse_client"] is None + request_dict = mocked_client_call.call_args.kwargs["request"] + assert request_dict["id"] == SYN_123 + assert request_dict["name"] == FOLDER_NAME + assert request_dict["parentId"] == PARENT_ID + assert request_dict["description"] == description + assert request_dict["concreteType"] == concrete_types.FOLDER_ENTITY # AND we should call the get method mocked_get.assert_called_once() @@ -425,9 +433,9 @@ def test_store_with_name_and_parent(self) -> None: folder.description = description # WHEN I call `store` with the Folder object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_folder_output()), ) as mocked_client_call, patch.object( self.syn, @@ -448,17 +456,19 @@ def test_store_with_name_and_parent(self) -> None: result = folder.store(parent=Folder(id=PARENT_ID), synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Folder( - id=folder.id, - name=folder.name, - parent=folder.parent_id, - description=description, - ), - set_annotations=False, - isRestricted=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == SYN_123 # From findEntityId mock + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the folder properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Folder" ) + assert request_dict["name"] == folder.name + assert request_dict["parentId"] == PARENT_ID + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_project.py b/tests/unit/synapseclient/models/synchronous/unit_test_project.py index e01d08da6..8e09f2e48 100644 --- a/tests/unit/synapseclient/models/synchronous/unit_test_project.py +++ b/tests/unit/synapseclient/models/synchronous/unit_test_project.py @@ -88,9 +88,9 @@ def test_store_with_id(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -107,14 +107,18 @@ def test_store_with_id(self) -> None: result = project.store(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_client_call.assert_called_once() + call_args = mocked_client_call.call_args + assert call_args.kwargs["entity_id"] == project.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["id"] == project.id + assert request_dict["description"] == description # AND we should call the get method mocked_get.assert_called_once() @@ -247,9 +251,9 @@ def test_store_after_get_with_changes(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_store, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -257,14 +261,18 @@ def test_store_after_get_with_changes(self) -> None: result = project.store(synapse_client=self.syn) # THEN we should call store because there are changes - mocked_store.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, + mocked_store.assert_called_once() + call_args = mocked_store.call_args + assert call_args.kwargs["entity_id"] == project.id + assert call_args.kwargs["new_version"] is False + assert call_args.kwargs["synapse_client"] == self.syn + # The request should be a dict with the project properties + request_dict = call_args.kwargs["request"] + assert ( + request_dict["concreteType"] == "org.sagebionetworks.repo.model.Project" ) + assert request_dict["id"] == project.id + assert request_dict["description"] == description # AND we should not call get as we already have mocked_get.assert_not_called() @@ -301,9 +309,9 @@ def test_store_with_annotations(self) -> None: with patch( "synapseclient.models.project.store_entity_components", return_value=(None), - ) as mocked_store_entity_components, patch.object( - self.syn, - "store", + ) as mocked_store_entity_components, patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch( "synapseclient.api.entity_factory.get_entity_id_bundle2", @@ -320,14 +328,14 @@ def test_store_with_annotations(self) -> None: result = project.store(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, - ) + mocked_client_call.assert_called_once() + assert mocked_client_call.call_args.kwargs["entity_id"] == PROJECT_ID + assert mocked_client_call.call_args.kwargs["new_version"] is False + assert mocked_client_call.call_args.kwargs["synapse_client"] == self.syn + request_dict = mocked_client_call.call_args.kwargs["request"] + assert request_dict["id"] == PROJECT_ID + assert request_dict["description"] == description + assert request_dict["concreteType"] == concrete_types.PROJECT_ENTITY # AND we should call the get method mocked_get.assert_called_once() @@ -362,9 +370,9 @@ def test_store_with_name_and_parent_id(self) -> None: project.description = description # WHEN I call `store` with the Project object - with patch.object( - self.syn, - "store", + with patch( + "synapseclient.models.services.storable_entity.put_entity", + new_callable=AsyncMock, return_value=(self.get_example_synapse_project_output()), ) as mocked_client_call, patch.object( self.syn, @@ -385,16 +393,16 @@ def test_store_with_name_and_parent_id(self) -> None: result = project.store(synapse_client=self.syn) # THEN we should call the method with this data - mocked_client_call.assert_called_once_with( - obj=Synapse_Project( - id=project.id, - name=project.name, - parent=project.parent_id, - description=description, - ), - set_annotations=False, - createOrUpdate=False, - ) + mocked_client_call.assert_called_once() + assert mocked_client_call.call_args.kwargs["entity_id"] == PROJECT_ID + assert mocked_client_call.call_args.kwargs["new_version"] is False + assert mocked_client_call.call_args.kwargs["synapse_client"] == self.syn + request_dict = mocked_client_call.call_args.kwargs["request"] + assert request_dict["id"] == PROJECT_ID + assert request_dict["name"] == PROJECT_NAME + assert request_dict["parentId"] == PARENT_ID + assert request_dict["description"] == description + assert request_dict["concreteType"] == concrete_types.PROJECT_ENTITY # AND we should call the get method mocked_get.assert_called_once()