From 9eb7b98eac12e2e8e3594bebd855480517cf6ae4 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 10 Sep 2025 22:28:10 +0000 Subject: [PATCH 01/11] Create factory operations for get, as well as creating the Link Entity class --- .gitignore | 1 + .../experimental/async/factory_operations.md | 30 + .../experimental/async/link_entity.md | 16 + .../experimental/sync/factory_operations.md | 30 + .../experimental/sync/link_entity.md | 16 + mkdocs.yml | 4 + synapseclient/api/__init__.py | 2 + synapseclient/api/entity_factory.py | 2 + synapseclient/api/entity_services.py | 91 ++ synapseclient/client.py | 27 +- synapseclient/core/async_utils.py | 7 +- .../core/upload/multipart_upload_async.py | 3 +- synapseclient/models/__init__.py | 17 + synapseclient/models/dataset.py | 8 +- synapseclient/models/factory_operations.py | 1209 +++++++++++++++++ synapseclient/models/folder.py | 14 +- synapseclient/models/link.py | 423 ++++++ .../models/mixins/table_components.py | 2 +- synapseclient/models/project.py | 13 +- .../models/services/storable_entity.py | 4 +- .../services/storable_entity_components.py | 5 +- synapseutils/sync.py | 9 +- tests/integration/conftest.py | 6 +- .../models/async/test_factory_operations.py | 738 ++++++++++ .../synchronous/test_factory_operations.py | 702 ++++++++++ 25 files changed, 3318 insertions(+), 61 deletions(-) create mode 100644 docs/reference/experimental/async/factory_operations.md create mode 100644 docs/reference/experimental/async/link_entity.md create mode 100644 docs/reference/experimental/sync/factory_operations.md create mode 100644 docs/reference/experimental/sync/link_entity.md create mode 100644 synapseclient/models/factory_operations.py create mode 100644 synapseclient/models/link.py create mode 100644 tests/integration/synapseclient/models/async/test_factory_operations.py create mode 100644 tests/integration/synapseclient/models/synchronous/test_factory_operations.py 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..5ce8d2f51 --- /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.models.get_async + +[](){ #factory-file-options-async } +::: synapseclient.models.FileOptions + options: + inherited_members: true + +[](){ #factory-activity-options-async } +::: synapseclient.models.ActivityOptions + options: + inherited_members: true + +[](){ #factory-table-options-async } +::: synapseclient.models.TableOptions + options: + inherited_members: true + +[](){ #factory-link-options-async } +::: synapseclient.models.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..11e48f251 --- /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.models.get + +[](){ #factory-file-options-sync } +::: synapseclient.models.FileOptions + options: + inherited_members: true + +[](){ #factory-activity-options-sync } +::: synapseclient.models.ActivityOptions + options: + inherited_members: true + +[](){ #factory-table-options-sync } +::: synapseclient.models.TableOptions + options: + inherited_members: true + +[](){ #factory-link-options-sync } +::: synapseclient.models.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..1f5083aa6 100644 --- a/synapseclient/api/entity_services.py +++ b/synapseclient/api/entity_services.py @@ -1264,6 +1264,97 @@ 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. + + 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..bea3e58f6 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -9,8 +9,17 @@ from synapseclient.models.annotations import Annotations from synapseclient.models.dataset import Dataset, DatasetCollection, EntityRef from synapseclient.models.entityview import EntityView, ViewTypeMask +from synapseclient.models.factory_operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, + get, + get_async, +) 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 +66,7 @@ "File", "FileHandle", "Folder", + "Link", "Project", "Annotations", "Team", @@ -112,6 +122,13 @@ "DatasetCollection", # Submission models "SubmissionView", + # Entity factory operations + "get", + "get_async", + "ActivityOptions", + "FileOptions", + "TableOptions", + "LinkOptions", ] # Static methods to expose as functions 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/factory_operations.py b/synapseclient/models/factory_operations.py new file mode 100644 index 000000000..7b6d72109 --- /dev/null +++ b/synapseclient/models/factory_operations.py @@ -0,0 +1,1209 @@ +"""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.models 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.models 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.models 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 False. + + Example: + Configure link following behavior: + + ```python + from synapseclient.models 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 = False + + +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: + 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, + 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 + + 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 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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.models 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, + ) + + # 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: + 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, + 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/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..3aa9eea63 --- /dev/null +++ b/synapseclient/models/link.py @@ -0,0 +1,423 @@ +"""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, 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, + ) + + +@dataclass() +@async_to_sync +class Link: + """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) + self.concrete_type = synapse_entity.get("concreteType", 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 = False, + *, + 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. + 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. + """ + 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.models import FileOptions + from synapseclient.models.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=FileOptions(download_file=False), + 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`: + + link_instance = await Link( + name="my_link", + parent_id="syn456", + target_id="syn123" + ).store_async() + """ + 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: + self.download_file = False + 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["File", None]: + """Determines if the file already exists in Synapse. If it does it will return + the file object, otherwise it will return None. This is used to determine if the + file 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, + ) + + 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..95ff3c796 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, diff --git a/synapseclient/models/services/storable_entity_components.py b/synapseclient/models/services/storable_entity_components.py index 76c7622da..698060696 100644 --- a/synapseclient/models/services/storable_entity_components.py +++ b/synapseclient/models/services/storable_entity_components.py @@ -11,6 +11,7 @@ EntityView, File, Folder, + Link, Project, SubmissionView, Table, @@ -50,7 +51,9 @@ async def wrap_coroutine( async def store_entity_components( - root_resource: Union["File", "Folder", "Project", "Table", "Dataset", "EntityView"], + root_resource: Union[ + "File", "Folder", "Project", "Table", "Dataset", "EntityView", "Link" + ], failure_strategy: FailureStrategy = FailureStrategy.LOG_EXCEPTION, *, synapse_client: Optional[Synapse] = None, 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/models/async/test_factory_operations.py b/tests/integration/synapseclient/models/async/test_factory_operations.py new file mode 100644 index 000000000..b3af4f369 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_factory_operations.py @@ -0,0 +1,738 @@ +"""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, + ActivityOptions, + Column, + ColumnType, + Dataset, + DatasetCollection, + EntityView, + File, + FileOptions, + Folder, + Link, + LinkOptions, + MaterializedView, + Project, + SubmissionView, + Table, + TableOptions, + UsedEntity, + UsedURL, + VirtualTable, + 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 temp_dir in 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_with_following( + self, project_model: Project + ) -> None: + """Test retrieving a Link entity by Synapse ID and following to the target.""" + # 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_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/models/synchronous/test_factory_operations.py b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py new file mode 100644 index 000000000..c7f266f46 --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py @@ -0,0 +1,702 @@ +"""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, + ActivityOptions, + Column, + ColumnType, + Dataset, + DatasetCollection, + EntityView, + File, + FileOptions, + Folder, + Link, + LinkOptions, + MaterializedView, + Project, + SubmissionView, + Table, + TableOptions, + UsedEntity, + UsedURL, + VirtualTable, + 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 temp_dir in 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_with_following(self, project_model: Project) -> None: + """Test retrieving a Link entity by Synapse ID and following to the target.""" + # 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_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) From 1171c0fce77fd7f3c7e193aadc02e136aa13a7e2 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 10 Sep 2025 23:20:39 +0000 Subject: [PATCH 02/11] Patch unit tests and issues found by copilot --- synapseclient/models/link.py | 1 - .../models/services/storable_entity.py | 21 --- .../models/async/unit_test_folder_async.py | 131 ++++++++++-------- .../models/async/unit_test_project_async.py | 98 +++++++------ .../models/synchronous/unit_test_folder.py | 130 +++++++++-------- .../models/synchronous/unit_test_project.py | 96 +++++++------ 6 files changed, 250 insertions(+), 227 deletions(-) diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py index 3aa9eea63..43c5d04da 100644 --- a/synapseclient/models/link.py +++ b/synapseclient/models/link.py @@ -375,7 +375,6 @@ async def store_async( root_resource=self, synapse_client=synapse_client ) if re_read_required: - self.download_file = False await self.get_async( synapse_client=synapse_client, ) diff --git a/synapseclient/models/services/storable_entity.py b/synapseclient/models/services/storable_entity.py index 95ff3c796..4bf4c31f4 100644 --- a/synapseclient/models/services/storable_entity.py +++ b/synapseclient/models/services/storable_entity.py @@ -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/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() From c5a50e42906ae37e06769b636b66eec665e1afa9 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:59:18 +0000 Subject: [PATCH 03/11] Fix path assertion in file retrieval tests to use normalized paths --- .../synapseclient/models/async/test_factory_operations.py | 4 +++- .../models/synchronous/test_factory_operations.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integration/synapseclient/models/async/test_factory_operations.py b/tests/integration/synapseclient/models/async/test_factory_operations.py index b3af4f369..33dc8c25f 100644 --- a/tests/integration/synapseclient/models/async/test_factory_operations.py +++ b/tests/integration/synapseclient/models/async/test_factory_operations.py @@ -202,7 +202,9 @@ async def test_get_async_file_by_id_with_file_options( assert retrieved_file.id == stored_file.id assert retrieved_file.download_file is True assert retrieved_file.if_collision == "overwrite.local" - assert temp_dir in retrieved_file.path + 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 diff --git a/tests/integration/synapseclient/models/synchronous/test_factory_operations.py b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py index c7f266f46..d5949cee2 100644 --- a/tests/integration/synapseclient/models/synchronous/test_factory_operations.py +++ b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py @@ -194,7 +194,9 @@ async def test_get_file_by_id_with_file_options( assert retrieved_file.id == stored_file.id assert retrieved_file.download_file is True assert retrieved_file.if_collision == "overwrite.local" - assert temp_dir in retrieved_file.path + 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.""" From 69790168fac2e49629f11b2a5ed993b3e5f8ccca Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:06:57 +0000 Subject: [PATCH 04/11] Fix version number assignment in _handle_simple_entity to check for attribute existence --- synapseclient/models/factory_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/models/factory_operations.py b/synapseclient/models/factory_operations.py index 7b6d72109..77774bbe9 100644 --- a/synapseclient/models/factory_operations.py +++ b/synapseclient/models/factory_operations.py @@ -270,7 +270,7 @@ async def _handle_simple_entity( Handle simple entities that only need basic setup (Project, Folder, DatasetCollection). """ entity = entity_class(id=synapse_id) - if version_number: + if version_number and hasattr(entity, "version_number"): entity.version_number = version_number return await entity.get_async(synapse_client=synapse_client) From b6492f6939c4a6a6e875211bed0955c44c4b03e7 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:44:41 +0000 Subject: [PATCH 05/11] Enhance Link functionality and add integration tests - Introduced LinkSynchronousProtocol to define synchronous interface for Link operations. - Updated Link class to implement LinkSynchronousProtocol, adding get and store methods with detailed docstrings. - Modified get_async method in Link class to include file_options parameter for enhanced file retrieval options. - Updated storable_entity_components to include DatasetCollection and MaterializedView in type hints. - Added comprehensive tests for Link retrieval, ensuring default behavior follows links and supports custom file options. --- synapseclient/models/factory_operations.py | 14 +- synapseclient/models/link.py | 225 +++++++++++++++++- .../services/storable_entity_components.py | 30 ++- ...ns.py => test_factory_operations_async.py} | 107 ++++++++- .../synchronous/test_factory_operations.py | 107 ++++++++- 5 files changed, 470 insertions(+), 13 deletions(-) rename tests/integration/synapseclient/models/async/{test_factory_operations.py => test_factory_operations_async.py} (87%) diff --git a/synapseclient/models/factory_operations.py b/synapseclient/models/factory_operations.py index 77774bbe9..dad2eb8b7 100644 --- a/synapseclient/models/factory_operations.py +++ b/synapseclient/models/factory_operations.py @@ -159,7 +159,7 @@ class LinkOptions: 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 False. + properties such as target_id, target_version, etc. Default is True. Example: Configure link following behavior: @@ -180,7 +180,7 @@ class LinkOptions: - When follow_link=False, a Link entity is always returned """ - follow_link: bool = False + follow_link: bool = True async def _handle_entity_instance( @@ -344,6 +344,7 @@ async def _handle_file_entity( 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", @@ -372,6 +373,9 @@ async def _handle_link_entity( if link_options: kwargs["follow_link"] = link_options.follow_link + if file_options: + kwargs["file_options"] = file_options + return await entity.get_async(**kwargs) @@ -1025,6 +1029,11 @@ async def main(): 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, @@ -1087,6 +1096,7 @@ async def main(): return await _handle_link_entity( synapse_id=synapse_id, link_options=link_options, + file_options=file_options, synapse_client=synapse_client, ) diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py index 43c5d04da..f10dcc4d1 100644 --- a/synapseclient/models/link.py +++ b/synapseclient/models/link.py @@ -4,7 +4,7 @@ from copy import deepcopy from dataclasses import dataclass, field, replace from datetime import date, datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union from synapseclient import Synapse from synapseclient.api import get_from_entity_factory @@ -31,11 +31,145 @@ Table, VirtualTable, ) + from synapseclient.models.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() + await syn.login() + + link_instance = Link( + name="my_link", + parent_id="syn456", + target_id="syn123" + ).store() + ``` + """ + return self @dataclass() @async_to_sync -class Link: +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). @@ -187,7 +321,6 @@ def fill_from_dict( self.created_by = synapse_entity.get("createdBy", None) self.modified_by = synapse_entity.get("modifiedBy", None) self.parent_id = synapse_entity.get("parentId", None) - self.concrete_type = synapse_entity.get("concreteType", None) # Handle nested Reference object links_to_data = synapse_entity.get("linksTo", None) @@ -244,7 +377,8 @@ def to_synapse_request(self) -> Dict[str, Any]: async def get_async( self, parent: Optional[Union["Folder", "Project"]] = None, - follow_link: bool = False, + follow_link: bool = True, + file_options: Optional["FileOptions"] = None, *, synapse_client: Optional[Synapse] = None, ) -> Union[ @@ -267,6 +401,8 @@ async def get_async( 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. @@ -277,6 +413,70 @@ async def get_async( 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() + await 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() + await 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() + await 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)): @@ -296,7 +496,6 @@ async def get_async( self._set_last_persistent_instance() if follow_link: - from synapseclient.models import FileOptions from synapseclient.models.factory_operations import ( get_async as factory_get_async, ) @@ -304,7 +503,7 @@ async def get_async( return await factory_get_async( synapse_id=self.target_id, version_number=self.target_version_number, - file_options=FileOptions(download_file=False), + file_options=file_options, synapse_client=synapse_client, ) else: @@ -338,11 +537,25 @@ async def store_async( 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() + await 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 diff --git a/synapseclient/models/services/storable_entity_components.py b/synapseclient/models/services/storable_entity_components.py index 698060696..f2c2f725d 100644 --- a/synapseclient/models/services/storable_entity_components.py +++ b/synapseclient/models/services/storable_entity_components.py @@ -8,13 +8,16 @@ if TYPE_CHECKING: from synapseclient.models import ( Dataset, + DatasetCollection, EntityView, File, Folder, Link, + MaterializedView, Project, SubmissionView, Table, + VirtualTable, ) @@ -52,7 +55,17 @@ async def wrap_coroutine( async def store_entity_components( root_resource: Union[ - "File", "Folder", "Project", "Table", "Dataset", "EntityView", "Link" + "Dataset", + "DatasetCollection", + "EntityView", + "File", + "Folder", + "Link", + "Project", + "MaterializedView", + "SubmissionView", + "Table", + "VirtualTable", ], failure_strategy: FailureStrategy = FailureStrategy.LOG_EXCEPTION, *, @@ -119,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 @@ -222,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/tests/integration/synapseclient/models/async/test_factory_operations.py b/tests/integration/synapseclient/models/async/test_factory_operations_async.py similarity index 87% rename from tests/integration/synapseclient/models/async/test_factory_operations.py rename to tests/integration/synapseclient/models/async/test_factory_operations_async.py index 33dc8c25f..ed37bf152 100644 --- a/tests/integration/synapseclient/models/async/test_factory_operations.py +++ b/tests/integration/synapseclient/models/async/test_factory_operations_async.py @@ -610,10 +610,70 @@ async def test_get_async_link_by_id_without_following( 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.""" + """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 @@ -644,6 +704,51 @@ async def test_get_async_link_by_id_with_following( 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 diff --git a/tests/integration/synapseclient/models/synchronous/test_factory_operations.py b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py index d5949cee2..e96bf4d37 100644 --- a/tests/integration/synapseclient/models/synchronous/test_factory_operations.py +++ b/tests/integration/synapseclient/models/synchronous/test_factory_operations.py @@ -576,8 +576,68 @@ async def test_get_link_by_id_without_following( 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.""" + """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 @@ -608,6 +668,51 @@ async def test_get_link_by_id_with_following(self, project_model: Project) -> No 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 From ea18aeaf8703f1ff03fd78ae920d113df7a224d5 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:54:39 +0000 Subject: [PATCH 06/11] Move the factory_operations.py module to an operations directory so we can logically group items differently --- synapseclient/models/__init__.py | 15 ------ synapseclient/models/link.py | 4 +- synapseclient/operations/__init__.py | 17 ++++++ .../factory_operations.py | 54 ++++++++++--------- .../operations/async/__init__.py | 0 .../async/test_factory_operations_async.py | 10 ++-- .../operations/synchronous/__init__.py | 0 .../synchronous/test_factory_operations.py | 10 ++-- 8 files changed, 59 insertions(+), 51 deletions(-) create mode 100644 synapseclient/operations/__init__.py rename synapseclient/{models => operations}/factory_operations.py (95%) create mode 100644 tests/integration/synapseclient/operations/async/__init__.py rename tests/integration/synapseclient/{models => operations}/async/test_factory_operations_async.py (99%) create mode 100644 tests/integration/synapseclient/operations/synchronous/__init__.py rename tests/integration/synapseclient/{models => operations}/synchronous/test_factory_operations.py (99%) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index bea3e58f6..dcd700fca 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -9,14 +9,6 @@ from synapseclient.models.annotations import Annotations from synapseclient.models.dataset import Dataset, DatasetCollection, EntityRef from synapseclient.models.entityview import EntityView, ViewTypeMask -from synapseclient.models.factory_operations import ( - ActivityOptions, - FileOptions, - LinkOptions, - TableOptions, - get, - get_async, -) from synapseclient.models.file import File, FileHandle from synapseclient.models.folder import Folder from synapseclient.models.link import Link @@ -122,13 +114,6 @@ "DatasetCollection", # Submission models "SubmissionView", - # Entity factory operations - "get", - "get_async", - "ActivityOptions", - "FileOptions", - "TableOptions", - "LinkOptions", ] # Static methods to expose as functions diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py index f10dcc4d1..b6a6d5141 100644 --- a/synapseclient/models/link.py +++ b/synapseclient/models/link.py @@ -31,7 +31,7 @@ Table, VirtualTable, ) - from synapseclient.models.factory_operations import FileOptions + from synapseclient.operations.factory_operations import FileOptions class LinkSynchronousProtocol(Protocol): @@ -496,7 +496,7 @@ async def get_link_with_file_options(): self._set_last_persistent_instance() if follow_link: - from synapseclient.models.factory_operations import ( + from synapseclient.operations.factory_operations import ( get_async as factory_get_async, ) 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/models/factory_operations.py b/synapseclient/operations/factory_operations.py similarity index 95% rename from synapseclient/models/factory_operations.py rename to synapseclient/operations/factory_operations.py index dad2eb8b7..282633bac 100644 --- a/synapseclient/models/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -51,7 +51,7 @@ class FileOptions: Configure file download options: ```python - from synapseclient.models import FileOptions + from synapseclient.operations import FileOptions # Download file to specific location with overwrite file_options = FileOptions( @@ -91,7 +91,7 @@ class ActivityOptions: Configure activity inclusion: ```python - from synapseclient.models import ActivityOptions + from synapseclient.operations import ActivityOptions # Include activity information with_activity = ActivityOptions(include_activity=True) @@ -131,7 +131,7 @@ class TableOptions: Configure table column inclusion: ```python - from synapseclient.models import TableOptions + from synapseclient.operations import TableOptions # Include column schema information with_columns = TableOptions(include_columns=True) @@ -165,7 +165,7 @@ class LinkOptions: Configure link following behavior: ```python - from synapseclient.models import LinkOptions + from synapseclient.operations import LinkOptions # Follow the link and return the target entity follow_target = LinkOptions(follow_link=True) @@ -457,7 +457,8 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get + from synapseclient.models import File, Project + from synapseclient.operations import get syn = Synapse() syn.login() @@ -477,7 +478,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get + from synapseclient.operations import get syn = Synapse() syn.login() @@ -500,7 +501,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get + from synapseclient.operations import get syn = Synapse() syn.login() @@ -513,7 +514,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, FileOptions, ActivityOptions + from synapseclient.operations import get, FileOptions, ActivityOptions syn = Synapse() syn.login() @@ -532,7 +533,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, ActivityOptions, TableOptions + from synapseclient.operations import get, ActivityOptions, TableOptions syn = Synapse() syn.login() @@ -549,7 +550,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, LinkOptions + from synapseclient.operations import get, LinkOptions syn = Synapse() syn.login() @@ -565,7 +566,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, LinkOptions + from synapseclient.operations import get, LinkOptions syn = Synapse() syn.login() @@ -590,7 +591,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, FileOptions + from synapseclient.operations import get, FileOptions syn = Synapse() syn.login() @@ -611,7 +612,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, TableOptions + from synapseclient.operations import get, TableOptions syn = Synapse() syn.login() @@ -630,7 +631,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, FileOptions, ActivityOptions + from synapseclient.operations import get, FileOptions, ActivityOptions syn = Synapse() syn.login() @@ -650,7 +651,7 @@ def get( ```python from synapseclient import Synapse - from synapseclient.models import get, FileOptions + from synapseclient.operations import get, FileOptions syn = Synapse() syn.login() @@ -756,7 +757,8 @@ async def get_async( ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async + from synapseclient.models import File, Project + from synapseclient.operations import get_async async def main(): syn = Synapse() @@ -780,7 +782,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async + from synapseclient.operations import get_async async def main(): syn = Synapse() @@ -807,7 +809,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async + from synapseclient.operations import get_async async def main(): syn = Synapse() @@ -824,7 +826,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, FileOptions, ActivityOptions + from synapseclient.operations import get_async, FileOptions, ActivityOptions async def main(): syn = Synapse() @@ -847,7 +849,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, ActivityOptions, TableOptions + from synapseclient.operations import get_async, ActivityOptions, TableOptions async def main(): syn = Synapse() @@ -868,7 +870,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, LinkOptions + from synapseclient.operations import get_async, LinkOptions async def main(): syn = Synapse() @@ -888,7 +890,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, LinkOptions + from synapseclient.operations import get_async, LinkOptions async def main(): syn = Synapse() @@ -917,7 +919,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, FileOptions + from synapseclient.operations import get_async, FileOptions async def main(): syn = Synapse() @@ -942,7 +944,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, TableOptions + from synapseclient.operations import get_async, TableOptions async def main(): syn = Synapse() @@ -965,7 +967,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, FileOptions, ActivityOptions + from synapseclient.operations import get_async, FileOptions, ActivityOptions async def main(): syn = Synapse() @@ -989,7 +991,7 @@ async def main(): ```python import asyncio from synapseclient import Synapse - from synapseclient.models import get_async, FileOptions + from synapseclient.operations import get_async, FileOptions async def main(): syn = Synapse() 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/models/async/test_factory_operations_async.py b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py similarity index 99% rename from tests/integration/synapseclient/models/async/test_factory_operations_async.py rename to tests/integration/synapseclient/operations/async/test_factory_operations_async.py index ed37bf152..bc8e04db4 100644 --- a/tests/integration/synapseclient/models/async/test_factory_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py @@ -11,25 +11,27 @@ from synapseclient.core import utils from synapseclient.models import ( Activity, - ActivityOptions, Column, ColumnType, Dataset, DatasetCollection, EntityView, File, - FileOptions, Folder, Link, - LinkOptions, MaterializedView, Project, SubmissionView, Table, - TableOptions, UsedEntity, UsedURL, VirtualTable, +) +from synapseclient.operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, get_async, ) 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/models/synchronous/test_factory_operations.py b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py similarity index 99% rename from tests/integration/synapseclient/models/synchronous/test_factory_operations.py rename to tests/integration/synapseclient/operations/synchronous/test_factory_operations.py index e96bf4d37..9da882a38 100644 --- a/tests/integration/synapseclient/models/synchronous/test_factory_operations.py +++ b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py @@ -11,25 +11,27 @@ from synapseclient.core import utils from synapseclient.models import ( Activity, - ActivityOptions, Column, ColumnType, Dataset, DatasetCollection, EntityView, File, - FileOptions, Folder, Link, - LinkOptions, MaterializedView, Project, SubmissionView, Table, - TableOptions, UsedEntity, UsedURL, VirtualTable, +) +from synapseclient.operations import ( + ActivityOptions, + FileOptions, + LinkOptions, + TableOptions, get, ) From ce4e26ad5b4db29df3143d95c4cb71d2756adcc4 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:57:16 +0000 Subject: [PATCH 07/11] Update factory_operations documentation to reference operations instead of models --- .../reference/experimental/async/factory_operations.md | 10 +++++----- docs/reference/experimental/sync/factory_operations.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/reference/experimental/async/factory_operations.md b/docs/reference/experimental/async/factory_operations.md index 5ce8d2f51..5e498c1fb 100644 --- a/docs/reference/experimental/async/factory_operations.md +++ b/docs/reference/experimental/async/factory_operations.md @@ -7,24 +7,24 @@ at your own risk. ## API Reference [](){ #factory-get-async } -::: synapseclient.models.get_async +::: synapseclient.operations.get_async [](){ #factory-file-options-async } -::: synapseclient.models.FileOptions +::: synapseclient.operations.FileOptions options: inherited_members: true [](){ #factory-activity-options-async } -::: synapseclient.models.ActivityOptions +::: synapseclient.operations.ActivityOptions options: inherited_members: true [](){ #factory-table-options-async } -::: synapseclient.models.TableOptions +::: synapseclient.operations.TableOptions options: inherited_members: true [](){ #factory-link-options-async } -::: synapseclient.models.LinkOptions +::: synapseclient.operations.LinkOptions options: inherited_members: true diff --git a/docs/reference/experimental/sync/factory_operations.md b/docs/reference/experimental/sync/factory_operations.md index 11e48f251..3a3bdb90c 100644 --- a/docs/reference/experimental/sync/factory_operations.md +++ b/docs/reference/experimental/sync/factory_operations.md @@ -7,24 +7,24 @@ at your own risk. ## API Reference [](){ #factory-get-sync } -::: synapseclient.models.get +::: synapseclient.operations.get [](){ #factory-file-options-sync } -::: synapseclient.models.FileOptions +::: synapseclient.operations.FileOptions options: inherited_members: true [](){ #factory-activity-options-sync } -::: synapseclient.models.ActivityOptions +::: synapseclient.operations.ActivityOptions options: inherited_members: true [](){ #factory-table-options-sync } -::: synapseclient.models.TableOptions +::: synapseclient.operations.TableOptions options: inherited_members: true [](){ #factory-link-options-sync } -::: synapseclient.models.LinkOptions +::: synapseclient.operations.LinkOptions options: inherited_members: true From 23e4db2b92ba5c15d674eb265548927e7c026d9e Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Tue, 30 Sep 2025 20:21:51 +0000 Subject: [PATCH 08/11] Update entity_services and link modules to improve API documentation --- synapseclient/api/entity_services.py | 2 ++ synapseclient/models/link.py | 17 +++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/synapseclient/api/entity_services.py b/synapseclient/api/entity_services.py index 1f5083aa6..759be644a 100644 --- a/synapseclient/api/entity_services.py +++ b/synapseclient/api/entity_services.py @@ -1275,6 +1275,8 @@ async def get_child( 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. diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py index b6a6d5141..fc4653996 100644 --- a/synapseclient/models/link.py +++ b/synapseclient/models/link.py @@ -155,7 +155,7 @@ def store( from synapseclient.models import Link syn = Synapse() - await syn.login() + syn.login() link_instance = Link( name="my_link", @@ -424,7 +424,7 @@ async def get_async( async def get_link_target(): syn = Synapse() - await syn.login() + syn.login() # Get the target entity that the link points to target_entity = await Link(id="syn123").get_async() @@ -443,7 +443,7 @@ async def get_link_target(): async def get_link_metadata(): syn = Synapse() - await syn.login() + syn.login() # Get just the link entity itself link_entity = await Link(id="syn123").get_async(follow_link=False) @@ -462,7 +462,7 @@ async def get_link_metadata(): async def get_link_with_file_options(): syn = Synapse() - await syn.login() + syn.login() # Follow link to file with custom download options file_entity = await Link(id="syn123").get_async( @@ -544,7 +544,7 @@ async def store_async( async def store_link(): syn = Synapse() - await syn.login() + syn.login() link_instance = await Link( name="my_link", @@ -598,9 +598,9 @@ async def store_link(): async def _find_existing_entity( self, *, synapse_client: Optional[Synapse] = None ) -> Union["File", None]: - """Determines if the file already exists in Synapse. If it does it will return - the file object, otherwise it will return None. This is used to determine if the - file should be updated or created.""" + """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 @@ -618,6 +618,7 @@ async def get_link(existing_id: str) -> "Link": ) return await link_copy.get_async( synapse_client=synapse_client, + follow_link=False, ) if ( From 636cd6f89ec8cbb889554c7966269d75087c31aa Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:31:26 +0000 Subject: [PATCH 09/11] Fix conditional check in get_async to ensure synapse_id is not provided before looking up by name --- synapseclient/operations/factory_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py index 282633bac..1c17bd7ff 100644 --- a/synapseclient/operations/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -1075,7 +1075,7 @@ async def main(): ) # If looking up by name, get the synapse_id first - if entity_name is not None: + 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 ) From 98ceb7dc3ded0890515a1062bf40a3b302ad0bc5 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 1 Oct 2025 21:31:59 +0000 Subject: [PATCH 10/11] Correct return type --- synapseclient/models/link.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapseclient/models/link.py b/synapseclient/models/link.py index fc4653996..1c317aea0 100644 --- a/synapseclient/models/link.py +++ b/synapseclient/models/link.py @@ -597,7 +597,7 @@ async def store_link(): async def _find_existing_entity( self, *, synapse_client: Optional[Synapse] = None - ) -> Union["File", 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.""" From 7e82f593c303a4b74e54470cde44052d7cb66333 Mon Sep 17 00:00:00 2001 From: BryanFauble <17128019+BryanFauble@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:34:54 -0400 Subject: [PATCH 11/11] Disable fail-fast in test job strategy --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) 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]