From 24f6742448608c3faca702b656a6d3b142f7e8d0 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 12 Feb 2026 12:11:29 -0500 Subject: [PATCH 01/18] initial commit --- synapseclient/api/__init__.py | 3 + synapseclient/api/docker_services.py | 41 ++ synapseclient/api/entity_factory.py | 2 + synapseclient/models/__init__.py | 2 + synapseclient/models/docker.py | 414 ++++++++++++++++++ .../models/protocols/docker_protocol.py | 158 +++++++ .../operations/factory_operations.py | 15 + 7 files changed, 635 insertions(+) create mode 100644 synapseclient/api/docker_services.py create mode 100644 synapseclient/models/docker.py create mode 100644 synapseclient/models/protocols/docker_protocol.py diff --git a/synapseclient/api/__init__.py b/synapseclient/api/__init__.py index 6b0961677..2f9e454ea 100644 --- a/synapseclient/api/__init__.py +++ b/synapseclient/api/__init__.py @@ -26,6 +26,7 @@ update_curation_task, ) from .docker_commit_services import get_docker_tag +from .docker_services import get_entity_id_by_repository_name from .entity_bundle_services_v2 import ( get_entity_id_bundle2, get_entity_id_version_bundle2, @@ -319,6 +320,8 @@ "update_curation_task", # docker_commit_services "get_docker_tag", + # docker_services + "get_entity_id_by_repository_name", # user_services "get_user_bundle", "get_user_by_principal_id_or_name", diff --git a/synapseclient/api/docker_services.py b/synapseclient/api/docker_services.py new file mode 100644 index 000000000..fa8cb7fd3 --- /dev/null +++ b/synapseclient/api/docker_services.py @@ -0,0 +1,41 @@ +"""This module is responsible for exposing the services defined at: + +""" + +import urllib.parse as urllib_parse +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from synapseclient import Synapse + + +async def get_entity_id_by_repository_name( + repository_name: str, + *, + synapse_client: Optional["Synapse"] = None, +) -> str: + """ + Get the Synapse entity ID for a managed Docker repository by its repository name. + + + + Arguments: + repository_name: The name of the managed Docker repository + 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 Synapse entity ID of the Docker repository. + + Raises: + SynapseHTTPError: If the repository is not found or is not a managed repository. + """ + from synapseclient import Synapse + + client = Synapse.get_client(synapse_client=synapse_client) + encoded_name = urllib_parse.quote(repository_name, safe="") + response = await client.rest_get_async( + uri=f"/entity/dockerRepo/id?repositoryName={encoded_name}", + ) + return response["id"] diff --git a/synapseclient/api/entity_factory.py b/synapseclient/api/entity_factory.py index ae4611e21..919af7227 100644 --- a/synapseclient/api/entity_factory.py +++ b/synapseclient/api/entity_factory.py @@ -335,6 +335,7 @@ class type. This will also download the file if `download_file` is set to True. Annotations, Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -379,6 +380,7 @@ class type. This will also download the file if `download_file` is set to True. concrete_types.SUBMISSION_VIEW: SubmissionView, concrete_types.VIRTUAL_TABLE: VirtualTable, concrete_types.LINK_ENTITY: Link, + concrete_types.DOCKER_REPOSITORY: DockerRepository, } entity_class = ENTITY_TYPE_MAP.get(entity["concreteType"], None) diff --git a/synapseclient/models/__init__.py b/synapseclient/models/__init__.py index 554de0bc2..7a85b6b83 100644 --- a/synapseclient/models/__init__.py +++ b/synapseclient/models/__init__.py @@ -14,6 +14,7 @@ RecordBasedMetadataTaskProperties, ) from synapseclient.models.dataset import Dataset, DatasetCollection, EntityRef +from synapseclient.models.docker import DockerRepository from synapseclient.models.entityview import EntityView, ViewTypeMask from synapseclient.models.evaluation import Evaluation from synapseclient.models.file import File, FileHandle @@ -78,6 +79,7 @@ "File", "FileHandle", "Folder", + "DockerRepository", "Link", "Project", "RecordSet", diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py new file mode 100644 index 000000000..f4ba61dbc --- /dev/null +++ b/synapseclient/models/docker.py @@ -0,0 +1,414 @@ +"""Docker repository dataclass model for Synapse entities.""" + +import asyncio +from dataclasses import dataclass, field, replace +from typing import Any, Dict, Optional + +from synapseclient import Synapse +from synapseclient.api.docker_services import get_entity_id_by_repository_name +from synapseclient.api.entity_bundle_services_v2 import get_entity_id_bundle2 +from synapseclient.core.async_utils import async_to_sync +from synapseclient.core.constants.concrete_types import DOCKER_REPOSITORY +from synapseclient.core.utils import delete_none_keys +from synapseclient.models.protocols.docker_protocol import ( + DockerRepositorySynchronousProtocol, +) +from synapseclient.models.services.storable_entity import store_entity + + +@dataclass() +@async_to_sync +class DockerRepository(DockerRepositorySynchronousProtocol): + """A Docker repository entity within Synapse. + + A Docker repository is a lightweight virtual machine image. + + Represents a [Synapse DockerRepository](https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/docker/DockerRepository.html). + + There are two types of Docker repositories in Synapse: + + - **Managed repositories** (`is_managed=True`): Hosted on Synapse's Docker registry + at `docker.synapse.org`. These cannot be created or modified via this API. To create + a managed Docker repository, you must push a Docker image directly to the Synapse + Docker registry. See the + [Synapse Docker Registry documentation](https://docs.synapse.org/synapse-docs/synapse-docker-registry) + for instructions. + + - **External repositories** (`is_managed=False`): References to Docker images hosted + on external registries like DockerHub or quay.io. These can be created and updated + using the `store()` method. + + Attributes: + 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. + 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. + 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 Project that is the parent of this Docker repository. + Docker repositories must be direct children of a Project, not a Folder. + repository_name: The name of the Docker Repository. Usually in the format: + [host[:port]/]path. If host is not set, it will default to that of DockerHub. + Port can only be specified if the host is also specified. + is_managed: (Read Only) Whether this Docker repository is managed by Synapse. + If True, the repository is hosted on Synapse's Docker registry. If False, + it references an external Docker registry. + """ + + 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.""" + + 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.""" + + 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 Project that is the parent of this Docker repository. + Docker repositories must be direct children of a Project, not a Folder.""" + + repository_name: Optional[str] = None + """The name of the Docker Repository. Usually in the format: [host[:port]/]path. + If host is not set, it will default to that of DockerHub. Port can only be + specified if the host is also specified.""" + + is_managed: Optional[bool] = None + """(Read Only) Whether this Docker repository is managed by Synapse. If True, + the repository is hosted on Synapse's Docker registry. If False, it references + an external Docker registry.""" + + _last_persistent_instance: Optional["DockerRepository"] = 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) + + def fill_from_dict(self, synapse_entity: Dict[str, Any]) -> "DockerRepository": + """ + Converts a response from the REST API into this dataclass. + + Arguments: + synapse_entity: The response from the REST API. + + Returns: + The DockerRepository object. + """ + self.id = synapse_entity.get("id", None) + self.name = synapse_entity.get("name", None) + self.description = synapse_entity.get("description", 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.repository_name = synapse_entity.get("repositoryName", None) + self.is_managed = synapse_entity.get("isManaged", None) + + 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 = { + "id": self.id, + "name": self.name, + "description": self.description, + "etag": self.etag, + "createdOn": self.created_on, + "modifiedOn": self.modified_on, + "createdBy": self.created_by, + "modifiedBy": self.modified_by, + "parentId": self.parent_id, + "concreteType": DOCKER_REPOSITORY, + "repositoryName": self.repository_name, + } + delete_none_keys(request_dict) + return request_dict + + async def get_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "DockerRepository": + """Get the Docker repository metadata from Synapse. + + You can retrieve a Docker repository by either: + + - `id`: The Synapse ID of the Docker repository (e.g., "syn123"). This works + for both managed and external Docker repositories. + - `repository_name`: The name of a **managed** Docker repository + (e.g., "docker.synapse.org/syn123/my-repo"). This lookup method **only works + for managed repositories** hosted on Synapse's Docker registry. External + (unmanaged) Docker repositories must be retrieved using their Synapse `id`. + + If both are provided, `id` takes precedence. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The DockerRepository object. + + Raises: + ValueError: If neither id nor repository_name is set. + SynapseHTTPError: If retrieving by repository_name and the repository + is not found or is not a managed repository. + + Example: Using this function + Retrieve a Docker repository by Synapse ID (works for both managed and + external repositories): + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + docker_repo = DockerRepository(id="syn123").get() + print(docker_repo) + ``` + + Retrieve a managed Docker repository by repository name (only works for + managed repositories hosted at docker.synapse.org): + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + docker_repo = DockerRepository( + repository_name="docker.synapse.org/syn123/my-repo" + ).get() + print(docker_repo) + ``` + """ + if not self.id and not self.repository_name: + raise ValueError( + "The Docker repository must have either an id or repository_name set." + ) + + # If we only have repository_name, look up the entity ID first + if not self.id and self.repository_name: + self.id = await self._get_entity_id_by_repository_name( + synapse_client=synapse_client, + ) + + bundle = await get_entity_id_bundle2( + entity_id=self.id, + request={"includeEntity": True}, + synapse_client=synapse_client, + ) + self.fill_from_dict(synapse_entity=bundle["entity"]) + + self._set_last_persistent_instance() + Synapse.get_client(synapse_client=synapse_client).logger.debug( + f"Got DockerRepository {self.repository_name}, id: {self.id}" + ) + return self + + async def _get_entity_id_by_repository_name( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> str: + """ + Get the Synapse entity ID for a managed Docker repository by its repository name. + + This uses the `GET /entity/dockerRepo/id` endpoint which only works for + **managed** Docker repositories (those hosted on Synapse's Docker registry). + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The Synapse entity ID of the Docker repository. + + Raises: + SynapseHTTPError: If the repository is not found or is not a managed repository. + """ + return await get_entity_id_by_repository_name( + repository_name=self.repository_name, + synapse_client=synapse_client, + ) + + async def store_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "DockerRepository": + """Store an external Docker repository in Synapse. + + This method is used to create or update **external** (unmanaged) Docker + repositories that reference images hosted on external registries like + DockerHub or quay.io. + + Note: **Managed** Docker repositories (hosted at docker.synapse.org) cannot + be created or modified via this method. To create a managed Docker repository + or to migrate an external repository to be managed, you must push Docker images + directly to the Synapse Docker registry. See the + [Synapse Docker Registry documentation](https://docs.synapse.org/synapse-docs/synapse-docker-registry) + for instructions. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The DockerRepository object. + + Raises: + ValueError: If the Docker repository does not have a parent_id or + repository_name set. + + Note: The `parent_id` must be a Project ID. Docker repositories cannot be + placed inside Folders - they must be direct children of a Project. + + Example: Using this function + Create an external Docker repository referencing a DockerHub image: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + async def create_external_docker_repo(): + syn = Synapse() + syn.login() + + # parent_id must be a Project ID + docker_repo = await DockerRepository( + parent_id="syn123", + repository_name="my-repo" + ).store_async() + return docker_repo + + docker_repo = asyncio.run(create_external_docker_repo()) + print(docker_repo.id) + ``` + """ + if not self.id and not self.parent_id: + raise ValueError( + "The Docker repository must have a parent_id set to store." + ) + if not self.id and not self.repository_name: + raise ValueError( + "The Docker repository must have a repository_name set to store." + ) + + 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) + + self._set_last_persistent_instance() + Synapse.get_client(synapse_client=synapse_client).logger.debug( + f"Stored DockerRepository {self.repository_name}, id: {self.id}" + ) + return self + + async def delete_async( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> None: + """Delete the Docker repository from Synapse. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Raises: + ValueError: If the Docker repository does not have an id set. + + Example: Using this function + Delete a Docker repository: + + ```python + import asyncio + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + async def delete_docker_repo(): + syn = Synapse() + syn.login() + + await DockerRepository(id="syn123").delete_async() + + # Run the async function + asyncio.run(delete_docker_repo()) + ``` + """ + if not self.id: + raise ValueError("The Docker repository must have an id set.") + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: Synapse.get_client(synapse_client=synapse_client).delete( + obj=self.id, + ), + ) + + Synapse.get_client(synapse_client=synapse_client).logger.debug( + f"Deleted DockerRepository {self.id}" + ) diff --git a/synapseclient/models/protocols/docker_protocol.py b/synapseclient/models/protocols/docker_protocol.py new file mode 100644 index 000000000..ce83650bd --- /dev/null +++ b/synapseclient/models/protocols/docker_protocol.py @@ -0,0 +1,158 @@ +"""Protocol defining the synchronous interface for DockerRepository operations.""" +from typing import TYPE_CHECKING, Optional, Protocol + +from synapseclient import Synapse + +if TYPE_CHECKING: + from synapseclient.models import DockerRepository + + +class DockerRepositorySynchronousProtocol(Protocol): + """Protocol defining the synchronous interface for DockerRepository operations.""" + + def get( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "DockerRepository": + """Get the Docker repository metadata from Synapse. + + You can retrieve a Docker repository by either: + + - `id`: The Synapse ID of the Docker repository (e.g., "syn123"). This works + for both managed and external Docker repositories. + - `repository_name`: The name of a **managed** Docker repository + (e.g., "docker.synapse.org/syn123/my-repo"). This lookup method **only works + for managed repositories** hosted on Synapse's Docker registry. External + (unmanaged) Docker repositories must be retrieved using their Synapse `id`. + + If both are provided, `id` takes precedence. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The DockerRepository object. + + Raises: + ValueError: If neither id nor repository_name is set. + SynapseHTTPError: If retrieving by repository_name and the repository + is not found or is not a managed repository. + + Example: Using this function + Retrieve a Docker repository by Synapse ID (works for both managed and + external repositories): + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + docker_repo = DockerRepository(id="syn123").get() + print(docker_repo.repository_name) + ``` + + Retrieve a managed Docker repository by repository name (only works for + managed repositories hosted at docker.synapse.org): + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + docker_repo = DockerRepository( + repository_name="docker.synapse.org/syn123/my-repo" + ).get() + print(docker_repo.id) + ``` + """ + return self + + def store( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> "DockerRepository": + """Store an external Docker repository in Synapse. + + This method is used to create or update **external** (unmanaged) Docker + repositories that reference images hosted on external registries like + DockerHub or quay.io. + + Note: **Managed** Docker repositories (hosted at docker.synapse.org) cannot + be created or modified via this method. To create a managed Docker repository + or to migrate an external repository to be managed, you must push Docker images + directly to the Synapse Docker registry. See the + [Synapse Docker Registry documentation](https://docs.synapse.org/synapse-docs/synapse-docker-registry) + for instructions. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Returns: + The DockerRepository object. + + Raises: + ValueError: If the Docker repository does not have a parent_id or + repository_name set. + + Note: The `parent_id` must be a Project ID. Docker repositories cannot be + placed inside Folders - they must be direct children of a Project. + + Example: Using this function + Create an external Docker repository referencing a DockerHub image: + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + # parent_id must be a Project ID + docker_repo = DockerRepository( + parent_id="syn123", + repository_name="dockerhub_username/my-repo" + ).store() + print(docker_repo.id) + ``` + """ + return self + + def delete( + self, + *, + synapse_client: Optional[Synapse] = None, + ) -> None: + """Delete the Docker repository from Synapse. + + Arguments: + synapse_client: If not passed in and caching was not disabled by + `Synapse.allow_client_caching(False)` this will use the last created + instance from the Synapse class constructor. + + Raises: + ValueError: If the Docker repository does not have an id set. + + Example: Using this function + Delete a Docker repository: + + ```python + from synapseclient import Synapse + from synapseclient.models import DockerRepository + + syn = Synapse() + syn.login() + + DockerRepository(id="syn123").delete() + ``` + """ + return None diff --git a/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py index 1c17bd7ff..ca9dff2f6 100644 --- a/synapseclient/operations/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -11,6 +11,7 @@ from synapseclient.models import ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -194,6 +195,7 @@ async def _handle_entity_instance( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -349,6 +351,7 @@ async def _handle_link_entity( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -393,6 +396,7 @@ def get( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -697,6 +701,7 @@ async def get_async( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -1020,6 +1025,7 @@ async def main(): from synapseclient.models import ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -1040,6 +1046,7 @@ async def main(): entity_types = ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -1197,6 +1204,14 @@ async def main(): synapse_client=synapse_client, ) + elif entity_type == concrete_types.DOCKER_REPOSITORY: + return await _handle_simple_entity( + entity_class=DockerRepository, + synapse_id=synapse_id, + version_number=version_number, + synapse_client=synapse_client, + ) + else: from synapseclient import Synapse From f1eb46c98f4560d43d863178384f99cda7ed04ec Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 12 Feb 2026 12:27:53 -0500 Subject: [PATCH 02/18] no need to change factory operations --- synapseclient/models/docker.py | 1 - synapseclient/operations/factory_operations.py | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py index f4ba61dbc..958ba78a2 100644 --- a/synapseclient/models/docker.py +++ b/synapseclient/models/docker.py @@ -248,7 +248,6 @@ async def get_async( bundle = await get_entity_id_bundle2( entity_id=self.id, - request={"includeEntity": True}, synapse_client=synapse_client, ) self.fill_from_dict(synapse_entity=bundle["entity"]) diff --git a/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py index ca9dff2f6..1c17bd7ff 100644 --- a/synapseclient/operations/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -11,7 +11,6 @@ from synapseclient.models import ( Dataset, DatasetCollection, - DockerRepository, EntityView, File, Folder, @@ -195,7 +194,6 @@ async def _handle_entity_instance( ) -> Union[ "Dataset", "DatasetCollection", - "DockerRepository", "EntityView", "File", "Folder", @@ -351,7 +349,6 @@ async def _handle_link_entity( ) -> Union[ "Dataset", "DatasetCollection", - "DockerRepository", "EntityView", "File", "Folder", @@ -396,7 +393,6 @@ def get( ) -> Union[ "Dataset", "DatasetCollection", - "DockerRepository", "EntityView", "File", "Folder", @@ -701,7 +697,6 @@ async def get_async( ) -> Union[ "Dataset", "DatasetCollection", - "DockerRepository", "EntityView", "File", "Folder", @@ -1025,7 +1020,6 @@ async def main(): from synapseclient.models import ( Dataset, DatasetCollection, - DockerRepository, EntityView, File, Folder, @@ -1046,7 +1040,6 @@ async def main(): entity_types = ( Dataset, DatasetCollection, - DockerRepository, EntityView, File, Folder, @@ -1204,14 +1197,6 @@ async def main(): synapse_client=synapse_client, ) - elif entity_type == concrete_types.DOCKER_REPOSITORY: - return await _handle_simple_entity( - entity_class=DockerRepository, - synapse_id=synapse_id, - version_number=version_number, - synapse_client=synapse_client, - ) - else: from synapseclient import Synapse From 03636dde59f523986a163969799c923f2876e01c Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 12 Feb 2026 12:41:19 -0500 Subject: [PATCH 03/18] update error messages --- synapseclient/models/docker.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py index 958ba78a2..c506fd431 100644 --- a/synapseclient/models/docker.py +++ b/synapseclient/models/docker.py @@ -333,7 +333,7 @@ async def create_external_docker_repo(): # parent_id must be a Project ID docker_repo = await DockerRepository( parent_id="syn123", - repository_name="my-repo" + repository_name="my-user-name/my-repo" ).store_async() return docker_repo @@ -341,13 +341,9 @@ async def create_external_docker_repo(): print(docker_repo.id) ``` """ - if not self.id and not self.parent_id: + if not self.parent_id or not self.repository_name: raise ValueError( - "The Docker repository must have a parent_id set to store." - ) - if not self.id and not self.repository_name: - raise ValueError( - "The Docker repository must have a repository_name set to store." + "Creating a new Docker repository requires both parent_id and repository_name." ) if self.has_changed: @@ -377,7 +373,7 @@ async def delete_async( instance from the Synapse class constructor. Raises: - ValueError: If the Docker repository does not have an id set. + ValueError: If the Docker repository does not have an id set for deletion. Example: Using this function Delete a Docker repository: @@ -398,7 +394,7 @@ async def delete_docker_repo(): ``` """ if not self.id: - raise ValueError("The Docker repository must have an id set.") + raise ValueError("Deleting a Docker repository requires an id to be set.") loop = asyncio.get_event_loop() await loop.run_in_executor( From d5476561133f56e509d45a8cb75f3908a00b5f5b Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 12 Feb 2026 12:42:30 -0500 Subject: [PATCH 04/18] remove changes to entity factory --- synapseclient/api/entity_factory.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/synapseclient/api/entity_factory.py b/synapseclient/api/entity_factory.py index 919af7227..ae4611e21 100644 --- a/synapseclient/api/entity_factory.py +++ b/synapseclient/api/entity_factory.py @@ -335,7 +335,6 @@ class type. This will also download the file if `download_file` is set to True. Annotations, Dataset, DatasetCollection, - DockerRepository, EntityView, File, Folder, @@ -380,7 +379,6 @@ class type. This will also download the file if `download_file` is set to True. concrete_types.SUBMISSION_VIEW: SubmissionView, concrete_types.VIRTUAL_TABLE: VirtualTable, concrete_types.LINK_ENTITY: Link, - concrete_types.DOCKER_REPOSITORY: DockerRepository, } entity_class = ENTITY_TYPE_MAP.get(entity["concreteType"], None) From fb9806ca8776b1000033275ef93b5c3bf4453cab Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 16 Feb 2026 13:48:36 -0500 Subject: [PATCH 05/18] allow setting annotations on docker repository; switch to use get_from_entity_factory; get operation can retrieve docker --- synapseclient/api/entity_factory.py | 2 + synapseclient/models/docker.py | 46 +++++++++++++++---- .../operations/factory_operations.py | 19 +++++++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/synapseclient/api/entity_factory.py b/synapseclient/api/entity_factory.py index ae4611e21..919af7227 100644 --- a/synapseclient/api/entity_factory.py +++ b/synapseclient/api/entity_factory.py @@ -335,6 +335,7 @@ class type. This will also download the file if `download_file` is set to True. Annotations, Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -379,6 +380,7 @@ class type. This will also download the file if `download_file` is set to True. concrete_types.SUBMISSION_VIEW: SubmissionView, concrete_types.VIRTUAL_TABLE: VirtualTable, concrete_types.LINK_ENTITY: Link, + concrete_types.DOCKER_REPOSITORY: DockerRepository, } entity_class = ENTITY_TYPE_MAP.get(entity["concreteType"], None) diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py index c506fd431..159e39a08 100644 --- a/synapseclient/models/docker.py +++ b/synapseclient/models/docker.py @@ -2,14 +2,17 @@ import asyncio from dataclasses import dataclass, field, replace -from typing import Any, Dict, Optional +from datetime import date, datetime +from typing import Any, Dict, List, Optional, Union from synapseclient import Synapse +from synapseclient.api import get_from_entity_factory from synapseclient.api.docker_services import get_entity_id_by_repository_name -from synapseclient.api.entity_bundle_services_v2 import get_entity_id_bundle2 from synapseclient.core.async_utils import async_to_sync from synapseclient.core.constants.concrete_types import DOCKER_REPOSITORY from synapseclient.core.utils import delete_none_keys +from synapseclient.models import Annotations +from synapseclient.models.mixins import AccessControllable from synapseclient.models.protocols.docker_protocol import ( DockerRepositorySynchronousProtocol, ) @@ -18,7 +21,7 @@ @dataclass() @async_to_sync -class DockerRepository(DockerRepositorySynchronousProtocol): +class DockerRepository(DockerRepositorySynchronousProtocol, AccessControllable): """A Docker repository entity within Synapse. A Docker repository is a lightweight virtual machine image. @@ -107,6 +110,25 @@ class DockerRepository(DockerRepositorySynchronousProtocol): the repository is hosted on Synapse's Docker registry. If False, it references an external Docker registry.""" + 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 folder. 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["DockerRepository"] = field( default=None, repr=False, compare=False ) @@ -126,12 +148,16 @@ def _set_last_persistent_instance(self) -> None: del self._last_persistent_instance self._last_persistent_instance = replace(self) - def fill_from_dict(self, synapse_entity: Dict[str, Any]) -> "DockerRepository": + def fill_from_dict( + self, synapse_entity: Dict[str, Any], set_annotations: bool = True + ) -> "DockerRepository": """ 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 DockerRepository object. @@ -148,6 +174,10 @@ def fill_from_dict(self, synapse_entity: Dict[str, Any]) -> "DockerRepository": self.repository_name = synapse_entity.get("repositoryName", None) self.is_managed = synapse_entity.get("isManaged", None) + if set_annotations: + self.annotations = Annotations.from_dict( + synapse_entity.get("annotations", None) + ) return self def to_synapse_request(self) -> Dict[str, Any]: @@ -246,11 +276,11 @@ async def get_async( synapse_client=synapse_client, ) - bundle = await get_entity_id_bundle2( - entity_id=self.id, + await get_from_entity_factory( + entity_to_update=self, + synapse_id_or_path=self.id, synapse_client=synapse_client, ) - self.fill_from_dict(synapse_entity=bundle["entity"]) self._set_last_persistent_instance() Synapse.get_client(synapse_client=synapse_client).logger.debug( @@ -352,7 +382,7 @@ async def create_external_docker_repo(): entity=self.to_synapse_request(), synapse_client=synapse_client, ) - self.fill_from_dict(synapse_entity=entity) + self.fill_from_dict(synapse_entity=entity, set_annotations=False) self._set_last_persistent_instance() Synapse.get_client(synapse_client=synapse_client).logger.debug( diff --git a/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py index 1c17bd7ff..a778d2fb9 100644 --- a/synapseclient/operations/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -11,6 +11,7 @@ from synapseclient.models import ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -194,6 +195,7 @@ async def _handle_entity_instance( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -265,9 +267,9 @@ async def _handle_simple_entity( synapse_id: str, version_number: Optional[int] = None, synapse_client: Optional["Synapse"] = None, -) -> Union["Project", "Folder"]: +) -> Union["Project", "Folder", "DockerRepository"]: """ - Handle simple entities that only need basic setup (Project, Folder, DatasetCollection). + Handle simple entities that only need basic setup (Project, Folder, DockerRepository). """ entity = entity_class(id=synapse_id) if version_number and hasattr(entity, "version_number"): @@ -349,6 +351,7 @@ async def _handle_link_entity( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -393,6 +396,7 @@ def get( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -697,6 +701,7 @@ async def get_async( ) -> Union[ "Dataset", "DatasetCollection", + "DockerRepository", "EntityView", "File", "Folder", @@ -1020,6 +1025,7 @@ async def main(): from synapseclient.models import ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -1040,6 +1046,7 @@ async def main(): entity_types = ( Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -1127,6 +1134,14 @@ async def main(): synapse_client=synapse_client, ) + elif entity_type == concrete_types.DOCKER_REPOSITORY: + return await _handle_simple_entity( + entity_class=DockerRepository, + 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, From e2cfd1dedf26ad46d44f809328d14fbe6202a6d1 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 16 Feb 2026 15:32:54 -0500 Subject: [PATCH 06/18] update delete method to use from delete_operations.py; add test --- synapseclient/models/docker.py | 9 +- synapseclient/operations/delete_operations.py | 5 + .../models/async/unit_test_docker_async.py | 255 ++++++++++++++++++ 3 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 tests/unit/synapseclient/models/async/unit_test_docker_async.py diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py index 159e39a08..4e5d9d3c1 100644 --- a/synapseclient/models/docker.py +++ b/synapseclient/models/docker.py @@ -17,6 +17,7 @@ DockerRepositorySynchronousProtocol, ) from synapseclient.models.services.storable_entity import store_entity +from synapseclient.operations import delete @dataclass() @@ -110,6 +111,9 @@ class DockerRepository(DockerRepositorySynchronousProtocol, AccessControllable): the repository is hosted on Synapse's Docker registry. If False, it references an external Docker registry.""" + concrete_type: Optional[str] = None + """Indicates which implementation of Entity this object represents. The value is the fully qualified class name, e.g. org.sagebionetworks.repo.model.FileEntity.""" + annotations: Optional[ Dict[ str, @@ -173,6 +177,7 @@ def fill_from_dict( self.parent_id = synapse_entity.get("parentId", None) self.repository_name = synapse_entity.get("repositoryName", None) self.is_managed = synapse_entity.get("isManaged", None) + self.concrete_type = synapse_entity.get("concreteType", None) if set_annotations: self.annotations = Annotations.from_dict( @@ -429,8 +434,8 @@ async def delete_docker_repo(): loop = asyncio.get_event_loop() await loop.run_in_executor( None, - lambda: Synapse.get_client(synapse_client=synapse_client).delete( - obj=self.id, + lambda: delete( + self.id, ), ) diff --git a/synapseclient/operations/delete_operations.py b/synapseclient/operations/delete_operations.py index 0976cde36..304780c8b 100644 --- a/synapseclient/operations/delete_operations.py +++ b/synapseclient/operations/delete_operations.py @@ -12,6 +12,7 @@ CurationTask, Dataset, DatasetCollection, + DockerRepository, EntityView, Evaluation, File, @@ -49,6 +50,7 @@ def delete( "Table", "Team", "VirtualTable", + "DockerRepository", ], version: Optional[Union[int, str]] = None, version_only: bool = False, @@ -277,6 +279,7 @@ async def delete_async( "Table", "Team", "VirtualTable", + "DockerRepository", ], version: Optional[Union[int, str]] = None, version_only: bool = False, @@ -432,6 +435,7 @@ async def main(): CurationTask, Dataset, DatasetCollection, + DockerRepository, EntityView, Evaluation, File, @@ -569,6 +573,7 @@ async def main(): SchemaOrganization, CurationTask, Grid, + DockerRepository, ), ): if version_only or final_version_for_entity is not None: diff --git a/tests/unit/synapseclient/models/async/unit_test_docker_async.py b/tests/unit/synapseclient/models/async/unit_test_docker_async.py new file mode 100644 index 000000000..e2617d037 --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_docker_async.py @@ -0,0 +1,255 @@ +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models import DockerRepository + +if TYPE_CHECKING: + from synapseclient import Synapse + + +TEST_ID = "syn1234" +TEST_NAME = "syn1234" +TEST_DESCRIPTION = "test description" +TEST_ETAG = "test-etag" +TEST_CREATED_ON = "2026-02-16T18:33:27.371" +TEST_MODIFIED_ON = "2026-02-16T18:33:27.371Z" +TEST_CREATED_BY = "5678" +TEST_MODIFIED_BY = "5678" +TEST_PARENT_ID = "syn111111" +TEST_REPOSITORY_NAME = "username/test" +TEST_IS_MANAGED = False +TEST_ANNOTATIONS = None +TEST_CONCRETE_TYPE = "org.sagebionetworks.repo.model.docker.DockerRepository" + + +class TestDockerRepository: + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def get_example_docker_output(self) -> dict[str, any]: + """Returns the entity dict that would be inside a bundle response.""" + return { + "id": TEST_ID, + "name": TEST_NAME, + "description": TEST_DESCRIPTION, + "etag": TEST_ETAG, + "createdOn": TEST_CREATED_ON, + "modifiedOn": TEST_MODIFIED_ON, + "createdBy": TEST_CREATED_BY, + "modifiedBy": TEST_MODIFIED_BY, + "parentId": TEST_PARENT_ID, + "concreteType": TEST_CONCRETE_TYPE, + "repositoryName": TEST_REPOSITORY_NAME, + "isManaged": TEST_IS_MANAGED, + } + + def get_example_bundle_response(self) -> dict[str, any]: + """Returns the full bundle response from the API.""" + return { + "entity": { + "id": TEST_ID, + "name": TEST_NAME, + "description": TEST_DESCRIPTION, + "etag": TEST_ETAG, + "createdOn": TEST_CREATED_ON, + "modifiedOn": TEST_MODIFIED_ON, + "createdBy": TEST_CREATED_BY, + "modifiedBy": TEST_MODIFIED_BY, + "parentId": TEST_PARENT_ID, + "concreteType": TEST_CONCRETE_TYPE, + "repositoryName": TEST_REPOSITORY_NAME, + "isManaged": TEST_IS_MANAGED, + }, + "entityType": "dockerrepo", + "annotations": { + "id": TEST_ID, + "etag": TEST_ETAG, + "annotations": TEST_ANNOTATIONS, + }, + "fileHandles": [], + "restrictionInformation": { + "objectId": int(TEST_ID.replace("syn", "")), + "restrictionLevel": "OPEN", + "hasUnmetAccessRequirement": False, + }, + } + + async def test_fill_from_dict(self) -> None: + docker_output = DockerRepository().fill_from_dict( + self.get_example_docker_output() + ) + + assert docker_output.id == TEST_ID + assert docker_output.name == TEST_NAME + assert docker_output.description == TEST_DESCRIPTION + assert docker_output.etag == TEST_ETAG + assert docker_output.created_on == TEST_CREATED_ON + assert docker_output.modified_on == TEST_MODIFIED_ON + assert docker_output.created_by == TEST_CREATED_BY + assert docker_output.modified_by == TEST_MODIFIED_BY + assert docker_output.parent_id == TEST_PARENT_ID + assert docker_output.repository_name == TEST_REPOSITORY_NAME + assert docker_output.is_managed == TEST_IS_MANAGED + assert docker_output.annotations == TEST_ANNOTATIONS + assert docker_output.concrete_type == TEST_CONCRETE_TYPE + + async def test_to_synapse_request(self): + docker = DockerRepository( + id=TEST_ID, + name=TEST_NAME, + description=TEST_DESCRIPTION, + etag=TEST_ETAG, + created_on=TEST_CREATED_ON, + modified_on=TEST_MODIFIED_ON, + created_by=TEST_CREATED_BY, + modified_by=TEST_MODIFIED_BY, + parent_id=TEST_PARENT_ID, + repository_name=TEST_REPOSITORY_NAME, + is_managed=TEST_IS_MANAGED, + annotations=TEST_ANNOTATIONS, + ) + + request_dict = docker.to_synapse_request() + assert request_dict["id"] == TEST_ID + assert request_dict["name"] == TEST_NAME + assert request_dict["description"] == TEST_DESCRIPTION + assert request_dict["etag"] == TEST_ETAG + assert request_dict["createdOn"] == TEST_CREATED_ON + assert request_dict["modifiedOn"] == TEST_MODIFIED_ON + assert request_dict["createdBy"] == TEST_CREATED_BY + assert request_dict["modifiedBy"] == TEST_MODIFIED_BY + assert request_dict["parentId"] == TEST_PARENT_ID + assert request_dict["repositoryName"] == TEST_REPOSITORY_NAME + + async def test_get_docker_by_id(self) -> None: + """Test getting a Docker repository by ID.""" + docker = DockerRepository(id=TEST_ID) + + # Mock get_from_entity_factory to simulate filling the entity with data + async def mock_get_from_entity_factory( + entity_to_update, synapse_id_or_path, synapse_client + ): + # Simulate what get_from_entity_factory does - it fills the entity in place + entity_to_update.fill_from_dict( + self.get_example_docker_output(), set_annotations=True + ) + + with patch( + "synapseclient.models.docker.get_from_entity_factory", + new_callable=AsyncMock, + side_effect=mock_get_from_entity_factory, + ) as mocked_get_from_factory: + result = await docker.get_async(synapse_client=self.syn) + + # Verify get_from_entity_factory was called with correct parameters + mocked_get_from_factory.assert_called_once_with( + entity_to_update=docker, + synapse_id_or_path=TEST_ID, + synapse_client=self.syn, + ) + + # Verify the entity was populated correctly + assert result.id == TEST_ID + assert result.name == TEST_NAME + assert result.description == TEST_DESCRIPTION + assert result.etag == TEST_ETAG + assert result.created_on == TEST_CREATED_ON + assert result.modified_on == TEST_MODIFIED_ON + assert result.created_by == TEST_CREATED_BY + assert result.modified_by == TEST_MODIFIED_BY + assert result.parent_id == TEST_PARENT_ID + assert result.repository_name == TEST_REPOSITORY_NAME + assert result.is_managed == TEST_IS_MANAGED + assert result.annotations == TEST_ANNOTATIONS + + async def test_get_docker_by_repository_name(self) -> None: + """Test getting a managed Docker repository by repository name.""" + docker = DockerRepository(repository_name=TEST_REPOSITORY_NAME) + + # Mock the repository name lookup + async def mock_get_entity_id_by_repository_name( + repository_name, synapse_client + ): + return TEST_ID + + # Mock get_from_entity_factory to simulate filling the entity with data + async def mock_get_from_entity_factory( + entity_to_update, synapse_id_or_path, synapse_client + ): + entity_to_update.fill_from_dict( + self.get_example_docker_output(), set_annotations=True + ) + + with patch( + "synapseclient.models.docker.get_entity_id_by_repository_name", + new_callable=AsyncMock, + side_effect=mock_get_entity_id_by_repository_name, + ) as mocked_get_id, patch( + "synapseclient.models.docker.get_from_entity_factory", + new_callable=AsyncMock, + side_effect=mock_get_from_entity_factory, + ) as mocked_get_from_factory: + result = await docker.get_async(synapse_client=self.syn) + + # Verify repository name lookup was called + mocked_get_id.assert_called_once_with( + repository_name=TEST_REPOSITORY_NAME, + synapse_client=self.syn, + ) + + # Verify get_from_entity_factory was called + mocked_get_from_factory.assert_called_once_with( + entity_to_update=docker, + synapse_id_or_path=TEST_ID, + synapse_client=self.syn, + ) + + # Verify the entity was populated correctly + assert result.id == TEST_ID + assert result.repository_name == TEST_REPOSITORY_NAME + + async def test_store_docker(self) -> None: + """Test storing a Docker repository.""" + docker = DockerRepository( + parent_id=TEST_PARENT_ID, + repository_name=TEST_REPOSITORY_NAME, + name=TEST_NAME, + description=TEST_DESCRIPTION, + ) + + with patch( + "synapseclient.models.docker.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_docker_output(), + ) as mocked_store: + result = await docker.store_async(synapse_client=self.syn) + + # Verify store_entity was called + mocked_store.assert_called_once() + call_args = mocked_store.call_args + assert call_args.kwargs["resource"] == docker + assert call_args.kwargs["synapse_client"] == self.syn + + # Verify the returned entity has the stored data + assert result.id == TEST_ID + assert result.name == TEST_NAME + assert result.description == TEST_DESCRIPTION + assert result.repository_name == TEST_REPOSITORY_NAME + + async def test_delete_docker(self) -> None: + """Test deleting a Docker repository.""" + docker = DockerRepository(id=TEST_ID) + + # Mock the delete function that's imported in docker.py + with patch( + "synapseclient.models.docker.delete", + return_value=None, + ) as mocked_delete: + await docker.delete_async(synapse_client=self.syn) + + # Verify delete was called with the entity ID + mocked_delete.assert_called_once_with(TEST_ID) From eed0af97f42309239793d341204c8a9125c726dc Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 16 Feb 2026 16:00:11 -0500 Subject: [PATCH 07/18] add async test --- .../models/async/unit_test_docker_async.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/unit/synapseclient/models/async/unit_test_docker_async.py b/tests/unit/synapseclient/models/async/unit_test_docker_async.py index e2617d037..01ea69318 100644 --- a/tests/unit/synapseclient/models/async/unit_test_docker_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_docker_async.py @@ -129,14 +129,22 @@ async def test_get_docker_by_id(self) -> None: """Test getting a Docker repository by ID.""" docker = DockerRepository(id=TEST_ID) - # Mock get_from_entity_factory to simulate filling the entity with data + # Mock get_from_entity_factory to simulate filling the entity with data. + # The implementation (see entity_factory.py:_cast_into_class_type): + # 1. Fetches entity bundle from Synapse API (get_entity_id_bundle2) + # 2. Calls _cast_into_class_type which calls entity.fill_from_dict(set_annotations=False) + # 3. Then separately sets entity.annotations from the bundle + test_annotation = {"anno": "value"} + async def mock_get_from_entity_factory( entity_to_update, synapse_id_or_path, synapse_client ): - # Simulate what get_from_entity_factory does - it fills the entity in place + from synapseclient.models import Annotations + entity_to_update.fill_from_dict( - self.get_example_docker_output(), set_annotations=True + self.get_example_docker_output(), set_annotations=False ) + entity_to_update.annotations = Annotations.from_dict(test_annotation) with patch( "synapseclient.models.docker.get_from_entity_factory", @@ -164,7 +172,7 @@ async def mock_get_from_entity_factory( assert result.parent_id == TEST_PARENT_ID assert result.repository_name == TEST_REPOSITORY_NAME assert result.is_managed == TEST_IS_MANAGED - assert result.annotations == TEST_ANNOTATIONS + assert result.annotations == test_annotation async def test_get_docker_by_repository_name(self) -> None: """Test getting a managed Docker repository by repository name.""" @@ -180,9 +188,13 @@ async def mock_get_entity_id_by_repository_name( async def mock_get_from_entity_factory( entity_to_update, synapse_id_or_path, synapse_client ): + from synapseclient.models import Annotations + entity_to_update.fill_from_dict( - self.get_example_docker_output(), set_annotations=True + self.get_example_docker_output(), set_annotations=False ) + # Separately set annotations to match real implementation + entity_to_update.annotations = Annotations.from_dict(TEST_ANNOTATIONS) with patch( "synapseclient.models.docker.get_entity_id_by_repository_name", From cfdc199e8e2c4edcff4cc9e90b29cde2240f4574 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Mon, 16 Feb 2026 16:20:11 -0500 Subject: [PATCH 08/18] edit test --- .../models/async/unit_test_docker_async.py | 9 +- .../models/synchronous/unit_test_docker.py | 266 ++++++++++++++++++ 2 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 tests/unit/synapseclient/models/synchronous/unit_test_docker.py diff --git a/tests/unit/synapseclient/models/async/unit_test_docker_async.py b/tests/unit/synapseclient/models/async/unit_test_docker_async.py index 01ea69318..e3cda21bb 100644 --- a/tests/unit/synapseclient/models/async/unit_test_docker_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_docker_async.py @@ -177,6 +177,7 @@ async def mock_get_from_entity_factory( async def test_get_docker_by_repository_name(self) -> None: """Test getting a managed Docker repository by repository name.""" docker = DockerRepository(repository_name=TEST_REPOSITORY_NAME) + test_annotation = {"anno": "value"} # Mock the repository name lookup async def mock_get_entity_id_by_repository_name( @@ -194,7 +195,7 @@ async def mock_get_from_entity_factory( self.get_example_docker_output(), set_annotations=False ) # Separately set annotations to match real implementation - entity_to_update.annotations = Annotations.from_dict(TEST_ANNOTATIONS) + entity_to_update.annotations = Annotations.from_dict(test_annotation) with patch( "synapseclient.models.docker.get_entity_id_by_repository_name", @@ -223,6 +224,7 @@ async def mock_get_from_entity_factory( # Verify the entity was populated correctly assert result.id == TEST_ID assert result.repository_name == TEST_REPOSITORY_NAME + assert result.annotations == test_annotation async def test_store_docker(self) -> None: """Test storing a Docker repository.""" @@ -242,9 +244,6 @@ async def test_store_docker(self) -> None: # Verify store_entity was called mocked_store.assert_called_once() - call_args = mocked_store.call_args - assert call_args.kwargs["resource"] == docker - assert call_args.kwargs["synapse_client"] == self.syn # Verify the returned entity has the stored data assert result.id == TEST_ID @@ -256,7 +255,7 @@ async def test_delete_docker(self) -> None: """Test deleting a Docker repository.""" docker = DockerRepository(id=TEST_ID) - # Mock the delete function that's imported in docker.py + # Mock the delete function with patch( "synapseclient.models.docker.delete", return_value=None, diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_docker.py b/tests/unit/synapseclient/models/synchronous/unit_test_docker.py new file mode 100644 index 000000000..1632a9a77 --- /dev/null +++ b/tests/unit/synapseclient/models/synchronous/unit_test_docker.py @@ -0,0 +1,266 @@ +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from synapseclient import Synapse +from synapseclient.models import DockerRepository + +if TYPE_CHECKING: + from synapseclient import Synapse + + +TEST_ID = "syn1234" +TEST_NAME = "syn1234" +TEST_DESCRIPTION = "test description" +TEST_ETAG = "test-etag" +TEST_CREATED_ON = "2026-02-16T18:33:27.371" +TEST_MODIFIED_ON = "2026-02-16T18:33:27.371Z" +TEST_CREATED_BY = "5678" +TEST_MODIFIED_BY = "5678" +TEST_PARENT_ID = "syn111111" +TEST_REPOSITORY_NAME = "username/test" +TEST_IS_MANAGED = False +TEST_ANNOTATIONS = None +TEST_CONCRETE_TYPE = "org.sagebionetworks.repo.model.docker.DockerRepository" + + +class TestDockerRepository: + @pytest.fixture(autouse=True, scope="function") + def init_syn(self, syn: Synapse) -> None: + self.syn = syn + + def get_example_docker_output(self) -> dict[str, any]: + """Returns the entity dict that would be inside a bundle response.""" + return { + "id": TEST_ID, + "name": TEST_NAME, + "description": TEST_DESCRIPTION, + "etag": TEST_ETAG, + "createdOn": TEST_CREATED_ON, + "modifiedOn": TEST_MODIFIED_ON, + "createdBy": TEST_CREATED_BY, + "modifiedBy": TEST_MODIFIED_BY, + "parentId": TEST_PARENT_ID, + "concreteType": TEST_CONCRETE_TYPE, + "repositoryName": TEST_REPOSITORY_NAME, + "isManaged": TEST_IS_MANAGED, + } + + def get_example_bundle_response(self) -> dict[str, any]: + """Returns the full bundle response from the API.""" + return { + "entity": { + "id": TEST_ID, + "name": TEST_NAME, + "description": TEST_DESCRIPTION, + "etag": TEST_ETAG, + "createdOn": TEST_CREATED_ON, + "modifiedOn": TEST_MODIFIED_ON, + "createdBy": TEST_CREATED_BY, + "modifiedBy": TEST_MODIFIED_BY, + "parentId": TEST_PARENT_ID, + "concreteType": TEST_CONCRETE_TYPE, + "repositoryName": TEST_REPOSITORY_NAME, + "isManaged": TEST_IS_MANAGED, + }, + "entityType": "dockerrepo", + "annotations": { + "id": TEST_ID, + "etag": TEST_ETAG, + "annotations": TEST_ANNOTATIONS, + }, + "fileHandles": [], + "restrictionInformation": { + "objectId": int(TEST_ID.replace("syn", "")), + "restrictionLevel": "OPEN", + "hasUnmetAccessRequirement": False, + }, + } + + def test_fill_from_dict(self) -> None: + docker_output = DockerRepository().fill_from_dict( + self.get_example_docker_output() + ) + + assert docker_output.id == TEST_ID + assert docker_output.name == TEST_NAME + assert docker_output.description == TEST_DESCRIPTION + assert docker_output.etag == TEST_ETAG + assert docker_output.created_on == TEST_CREATED_ON + assert docker_output.modified_on == TEST_MODIFIED_ON + assert docker_output.created_by == TEST_CREATED_BY + assert docker_output.modified_by == TEST_MODIFIED_BY + assert docker_output.parent_id == TEST_PARENT_ID + assert docker_output.repository_name == TEST_REPOSITORY_NAME + assert docker_output.is_managed == TEST_IS_MANAGED + assert docker_output.annotations == TEST_ANNOTATIONS + assert docker_output.concrete_type == TEST_CONCRETE_TYPE + + def test_to_synapse_request(self): + docker = DockerRepository( + id=TEST_ID, + name=TEST_NAME, + description=TEST_DESCRIPTION, + etag=TEST_ETAG, + created_on=TEST_CREATED_ON, + modified_on=TEST_MODIFIED_ON, + created_by=TEST_CREATED_BY, + modified_by=TEST_MODIFIED_BY, + parent_id=TEST_PARENT_ID, + repository_name=TEST_REPOSITORY_NAME, + is_managed=TEST_IS_MANAGED, + annotations=TEST_ANNOTATIONS, + ) + + request_dict = docker.to_synapse_request() + assert request_dict["id"] == TEST_ID + assert request_dict["name"] == TEST_NAME + assert request_dict["description"] == TEST_DESCRIPTION + assert request_dict["etag"] == TEST_ETAG + assert request_dict["createdOn"] == TEST_CREATED_ON + assert request_dict["modifiedOn"] == TEST_MODIFIED_ON + assert request_dict["createdBy"] == TEST_CREATED_BY + assert request_dict["modifiedBy"] == TEST_MODIFIED_BY + assert request_dict["parentId"] == TEST_PARENT_ID + assert request_dict["repositoryName"] == TEST_REPOSITORY_NAME + + def test_get_docker_by_id(self) -> None: + """Test getting a Docker repository by ID.""" + docker = DockerRepository(id=TEST_ID) + + # Mock get_from_entity_factory to simulate filling the entity with data. + # The real implementation (see entity_factory.py:_cast_into_class_type): + # 1. Fetches entity bundle from Synapse API (get_entity_id_bundle2) + # 2. Calls _cast_into_class_type which calls entity.fill_from_dict(set_annotations=False) + # 3. Then separately sets entity.annotations from the bundle + test_annotation = {"anno": "value"} + + async def mock_get_from_entity_factory( + entity_to_update, synapse_id_or_path, synapse_client + ): + from synapseclient.models import Annotations + + entity_to_update.fill_from_dict( + self.get_example_docker_output(), set_annotations=False + ) + entity_to_update.annotations = Annotations.from_dict(test_annotation) + + with patch( + "synapseclient.models.docker.get_from_entity_factory", + new_callable=AsyncMock, + side_effect=mock_get_from_entity_factory, + ) as mocked_get_from_factory: + result = docker.get(synapse_client=self.syn) + + # Verify get_from_entity_factory was called with correct parameters + mocked_get_from_factory.assert_called_once_with( + entity_to_update=docker, + synapse_id_or_path=TEST_ID, + synapse_client=self.syn, + ) + + # Verify the entity was populated correctly + assert result.id == TEST_ID + assert result.name == TEST_NAME + assert result.description == TEST_DESCRIPTION + assert result.etag == TEST_ETAG + assert result.created_on == TEST_CREATED_ON + assert result.modified_on == TEST_MODIFIED_ON + assert result.created_by == TEST_CREATED_BY + assert result.modified_by == TEST_MODIFIED_BY + assert result.parent_id == TEST_PARENT_ID + assert result.repository_name == TEST_REPOSITORY_NAME + assert result.is_managed == TEST_IS_MANAGED + assert result.annotations == test_annotation + + def test_get_docker_by_repository_name(self) -> None: + """Test getting a managed Docker repository by repository name.""" + docker = DockerRepository(repository_name=TEST_REPOSITORY_NAME) + test_annotation = {"anno": "value"} + + # Mock the repository name lookup + async def mock_get_entity_id_by_repository_name( + repository_name, synapse_client + ): + return TEST_ID + + # Mock get_from_entity_factory to simulate filling the entity with data + async def mock_get_from_entity_factory( + entity_to_update, synapse_id_or_path, synapse_client + ): + from synapseclient.models import Annotations + + entity_to_update.fill_from_dict( + self.get_example_docker_output(), set_annotations=False + ) + # Separately set annotations to match real implementation + entity_to_update.annotations = Annotations.from_dict(test_annotation) + + with patch( + "synapseclient.models.docker.get_entity_id_by_repository_name", + new_callable=AsyncMock, + side_effect=mock_get_entity_id_by_repository_name, + ) as mocked_get_id, patch( + "synapseclient.models.docker.get_from_entity_factory", + new_callable=AsyncMock, + side_effect=mock_get_from_entity_factory, + ) as mocked_get_from_factory: + result = docker.get(synapse_client=self.syn) + + # Verify repository name lookup was called + mocked_get_id.assert_called_once_with( + repository_name=TEST_REPOSITORY_NAME, + synapse_client=self.syn, + ) + + # Verify get_from_entity_factory was called + mocked_get_from_factory.assert_called_once_with( + entity_to_update=docker, + synapse_id_or_path=TEST_ID, + synapse_client=self.syn, + ) + + # Verify the entity was populated correctly + assert result.id == TEST_ID + assert result.repository_name == TEST_REPOSITORY_NAME + assert result.annotations == test_annotation + + def test_store_docker(self) -> None: + """Test storing a Docker repository.""" + docker = DockerRepository( + parent_id=TEST_PARENT_ID, + repository_name=TEST_REPOSITORY_NAME, + name=TEST_NAME, + description=TEST_DESCRIPTION, + ) + + with patch( + "synapseclient.models.docker.store_entity", + new_callable=AsyncMock, + return_value=self.get_example_docker_output(), + ) as mocked_store: + result = docker.store(synapse_client=self.syn) + + # Verify store_entity was called + mocked_store.assert_called_once() + + # Verify the returned entity has the stored data + assert result.id == TEST_ID + assert result.name == TEST_NAME + assert result.description == TEST_DESCRIPTION + assert result.repository_name == TEST_REPOSITORY_NAME + + def test_delete_docker(self) -> None: + """Test deleting a Docker repository.""" + docker = DockerRepository(id=TEST_ID) + + # Mock the delete function that's imported in docker.py + with patch( + "synapseclient.models.docker.delete", + return_value=None, + ) as mocked_delete: + docker.delete(synapse_client=self.syn) + + # Verify delete was called with the entity ID + mocked_delete.assert_called_once_with(TEST_ID) From 673fd3b762ee82af9342854aeb09009ac4c542c3 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Tue, 17 Feb 2026 14:14:39 -0500 Subject: [PATCH 09/18] add back _handle_simple_entity --- 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 a778d2fb9..904bd4295 100644 --- a/synapseclient/operations/factory_operations.py +++ b/synapseclient/operations/factory_operations.py @@ -269,7 +269,7 @@ async def _handle_simple_entity( synapse_client: Optional["Synapse"] = None, ) -> Union["Project", "Folder", "DockerRepository"]: """ - Handle simple entities that only need basic setup (Project, Folder, DockerRepository). + Handle simple entities that only need basic setup (Project, Folder, DatasetCollection, DockerRepository). """ entity = entity_class(id=synapse_id) if version_number and hasattr(entity, "version_number"): From d70197fce1f84419ab0d97c09124cb687d0dda9b Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Wed, 18 Feb 2026 14:45:47 -0500 Subject: [PATCH 10/18] add test for docker repository --- synapseclient/models/docker.py | 1 + .../models/async/test_docker_async.py | 143 ++++++++++++++++++ .../models/synchronous/test_docker.py | 135 +++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 tests/integration/synapseclient/models/async/test_docker_async.py create mode 100644 tests/integration/synapseclient/models/synchronous/test_docker.py diff --git a/synapseclient/models/docker.py b/synapseclient/models/docker.py index 4e5d9d3c1..c45d61c68 100644 --- a/synapseclient/models/docker.py +++ b/synapseclient/models/docker.py @@ -436,6 +436,7 @@ async def delete_docker_repo(): None, lambda: delete( self.id, + synapse_client=synapse_client, ), ) diff --git a/tests/integration/synapseclient/models/async/test_docker_async.py b/tests/integration/synapseclient/models/async/test_docker_async.py new file mode 100644 index 000000000..ef95b0ac2 --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_docker_async.py @@ -0,0 +1,143 @@ +"""Integration tests for the synapseclient.models.DockerRepository class (async).""" + +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.models import DockerRepository, Project + + +class TestDockerRepositoryAsync: + """Async integration tests for DockerRepository.""" + + @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 + + @pytest.fixture(scope="function") + async def test_docker_repo( + self, schedule_for_cleanup: Callable[..., None] + ) -> DockerRepository: + """Create a test docker repository for testing.""" + # GIVEN a project to work with + project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( + synapse_client=self.syn + ) + self.schedule_for_cleanup(project.id) + + # Create a DockerRepository entity + docker_repo = DockerRepository( + parent_id=project.id, repository_name="username/test-async" + ) + await docker_repo.store_async(synapse_client=self.syn) + schedule_for_cleanup(docker_repo.id) + return docker_repo + + async def test_get_docker_repo(self, test_docker_repo: DockerRepository) -> None: + """Test retrieving a Docker repository by ID (async).""" + # GIVEN an existing Docker repository + + # WHEN we retrieve it by ID + retrieved_docker_repo = await DockerRepository( + id=test_docker_repo.id, + ).get_async(synapse_client=self.syn) + + # THEN the retrieved DockerRepository should match the created one + assert retrieved_docker_repo.id == test_docker_repo.id + assert retrieved_docker_repo.repository_name == test_docker_repo.repository_name + assert retrieved_docker_repo.parent_id == test_docker_repo.parent_id + + # Metadata fields (set by Synapse on creation) + assert retrieved_docker_repo.etag is not None + assert retrieved_docker_repo.created_on is not None + assert retrieved_docker_repo.modified_on is not None + assert retrieved_docker_repo.created_by is not None + assert retrieved_docker_repo.modified_by is not None + + # Repository type + assert retrieved_docker_repo.is_managed == False # External repo + assert ( + retrieved_docker_repo.concrete_type + == "org.sagebionetworks.repo.model.docker.DockerRepository" + ) + + async def test_get_docker_repo_missing_id_raises_error(self) -> None: + """Test that get_async() raises ValueError when neither id nor repository_name is set.""" + docker_repo = DockerRepository() + + with pytest.raises( + ValueError, match="must have either an id or repository_name" + ): + await docker_repo.get_async(synapse_client=self.syn) + + async def test_get_docker_repo_with_optional_fields( + self, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test retrieving a Docker repository with all optional fields set (async).""" + # GIVEN a project and DockerRepository with all fields + project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( + synapse_client=self.syn + ) + schedule_for_cleanup(project.id) + + docker_repo = await DockerRepository( + parent_id=project.id, + repository_name="username/test-async-optional", + name="My Test Repo Async", + description="A test repository with all fields (async)", + ).store_async(synapse_client=self.syn) + schedule_for_cleanup(docker_repo.id) + + # WHEN we retrieve it + retrieved = await DockerRepository(id=docker_repo.id).get_async( + synapse_client=self.syn + ) + + # THEN optional fields should be preserved + assert retrieved.name == "My Test Repo Async" + assert retrieved.description == "A test repository with all fields (async)" + + async def test_update_docker_repo_description( + self, test_docker_repo: DockerRepository + ) -> None: + """Test updating the description of a Docker repository (async).""" + # GIVEN an existing Docker repository + original_description = test_docker_repo.description + + # WHEN we update the description + new_description = "Updated description for testing (async)" + test_docker_repo.description = new_description + updated_repo = await test_docker_repo.store_async(synapse_client=self.syn) + + # THEN the description should be updated, and other fields should remain unchanged + assert updated_repo.description == new_description + assert updated_repo.id == test_docker_repo.id + assert updated_repo.repository_name == test_docker_repo.repository_name + assert updated_repo.parent_id == test_docker_repo.parent_id + # Note: etag, modified_on may change on update + assert updated_repo.created_on == test_docker_repo.created_on + assert updated_repo.created_by == test_docker_repo.created_by + + async def test_create_docker_repo_without_parent_raises_error(self) -> None: + """Test that creating a Docker repo without parent_id raises error (async).""" + docker_repo = DockerRepository(repository_name="username/test-async-no-parent") + + with pytest.raises(ValueError, match="parent_id"): + await docker_repo.store_async(synapse_client=self.syn) + + async def test_delete_docker_repo(self, test_docker_repo: DockerRepository) -> None: + """Test deleting a Docker repository (async).""" + # GIVEN an existing Docker repository + repo_id = test_docker_repo.id + + # WHEN we delete it + await test_docker_repo.delete_async(synapse_client=self.syn) + + # THEN it should no longer be retrievable + with pytest.raises( + Exception, match=f"404 Client Error: Entity {repo_id} is in trash can" + ): + await DockerRepository(id=repo_id).get_async(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_docker.py b/tests/integration/synapseclient/models/synchronous/test_docker.py new file mode 100644 index 000000000..ba790709a --- /dev/null +++ b/tests/integration/synapseclient/models/synchronous/test_docker.py @@ -0,0 +1,135 @@ +import uuid +from typing import Callable + +import pytest + +from synapseclient import Synapse +from synapseclient.models import DockerRepository, Project + + +class TestDockerRepository: + @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 + + @pytest.fixture(scope="function") + def test_docker_repo( + self, schedule_for_cleanup: Callable[..., None] + ) -> DockerRepository: + """Create a test docker repository for testing.""" + # GIVEN a project to work with + project = Project(name=f"test_project_{uuid.uuid4()}").store( + synapse_client=self.syn + ) + self.schedule_for_cleanup(project.id) + + # Create a DockerRepository entity + docker_repo = DockerRepository( + parent_id=project.id, repository_name="username/test" + ) + schedule_for_cleanup(project.id) + docker_repo.store(synapse_client=self.syn) + return docker_repo + + def test_get_docker_repo(self, test_docker_repo: DockerRepository) -> None: + # GIVEN a project to work with + retrieved_docker_repo = DockerRepository( + id=test_docker_repo.id, + ).get(synapse_client=self.syn) + + # THEN the retrieved DockerRepository should match the created one + assert retrieved_docker_repo.id == test_docker_repo.id + assert retrieved_docker_repo.repository_name == test_docker_repo.repository_name + assert retrieved_docker_repo.parent_id == test_docker_repo.parent_id + + # Metadata fields (set by Synapse on creation) + assert retrieved_docker_repo.etag is not None + assert retrieved_docker_repo.created_on is not None + assert retrieved_docker_repo.modified_on is not None + assert retrieved_docker_repo.created_by is not None + assert retrieved_docker_repo.modified_by is not None + + # Repository type + assert retrieved_docker_repo.is_managed == False # External repo + assert ( + retrieved_docker_repo.concrete_type + == "org.sagebionetworks.repo.model.docker.DockerRepository" + ) + + def test_get_docker_repo_missing_id_raises_error(self) -> None: + """Test that get() raises ValueError when neither id nor repository_name is set.""" + docker_repo = DockerRepository() + + with pytest.raises( + ValueError, match="must have either an id or repository_name" + ): + docker_repo.get(synapse_client=self.syn) + + def test_get_docker_repo_with_optional_fields( + self, schedule_for_cleanup: Callable[..., None] + ) -> None: + """Test retrieving a Docker repository with all optional fields set.""" + # GIVEN a project and DockerRepository with all fields + project = Project(name=f"test_project_{uuid.uuid4()}").store( + synapse_client=self.syn + ) + schedule_for_cleanup(project.id) + + docker_repo = DockerRepository( + parent_id=project.id, + repository_name="username/test", + name="My Test Repo", + description="A test repository with all fields", + ).store(synapse_client=self.syn) + + # WHEN we retrieve it + retrieved = DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + + # THEN optional fields should be preserved + assert retrieved.name == "My Test Repo" + assert retrieved.description == "A test repository with all fields" + + def test_update_docker_repo_description( + self, test_docker_repo: DockerRepository + ) -> None: + """Test updating the description of a Docker repository.""" + # GIVEN an existing Docker repository + original_description = test_docker_repo.description + + # WHEN we update the description + new_description = "Updated description for testing" + test_docker_repo.description = new_description + updated_repo = test_docker_repo.store(synapse_client=self.syn) + + # THEN the description should be updated, and other fields should remain unchanged + assert updated_repo.description == new_description + assert updated_repo.id == test_docker_repo.id + assert updated_repo.repository_name == test_docker_repo.repository_name + assert updated_repo.parent_id == test_docker_repo.parent_id + assert updated_repo.etag == test_docker_repo.etag + assert updated_repo.created_on == test_docker_repo.created_on + assert updated_repo.modified_on == test_docker_repo.modified_on + assert updated_repo.created_by == test_docker_repo.created_by + assert updated_repo.modified_by == test_docker_repo.modified_by + + def test_create_docker_repo_without_parent_raises_error(self) -> None: + """Test that creating a Docker repo without parent_id raises error.""" + docker_repo = DockerRepository(repository_name="username/test") + + with pytest.raises(ValueError, match="parent_id"): + docker_repo.store(synapse_client=self.syn) + + def test_delete_docker_repo(self, test_docker_repo: DockerRepository) -> None: + """Test deleting a Docker repository.""" + # GIVEN an existing Docker repository + repo_id = test_docker_repo.id + + # WHEN we delete it + test_docker_repo.delete(synapse_client=self.syn) + + # THEN it should no longer be retrievable + with pytest.raises( + Exception, match=f"404 Client Error: Entity {repo_id} is in trash can" + ): + DockerRepository(id=repo_id).get(synapse_client=self.syn) From c1024354a0817b3cc820ab93de4a61087672303b Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Wed, 18 Feb 2026 15:08:04 -0500 Subject: [PATCH 11/18] add test of deleting docker repo using delete in factory operation --- .../synchronous/test_delete_operations.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py b/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py index 85f8c6d73..c344fcdcb 100644 --- a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py +++ b/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py @@ -7,7 +7,7 @@ from synapseclient import Synapse from synapseclient.core import utils from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import File, Project, RecordSet +from synapseclient.models import DockerRepository, File, Project, RecordSet from synapseclient.operations import delete @@ -469,3 +469,41 @@ def test_delete_file_with_version_number_none_no_warning( assert not any( "Version conflict" in record.message for record in caplog.records ) + + def test_delete_docker_repo_by_id_string(self, project_model: Project) -> None: + """Test deleting a Docker repository using a string ID.""" + # GIVEN a Docker repository stored in synapse + docker_repo = DockerRepository( + parent_id=project_model.id, + repository_name="username/test-delete-string", + ).store(synapse_client=self.syn) + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I delete using string ID + delete(docker_repo.id, synapse_client=self.syn) + + # THEN the repository should be deleted + with pytest.raises(SynapseHTTPError) as e: + DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( + e.value + ) + + def test_delete_docker_repo_by_object(self, project_model: Project) -> None: + """Test deleting a Docker repository using a DockerRepository object.""" + # GIVEN a Docker repository stored in synapse + docker_repo = DockerRepository( + parent_id=project_model.id, + repository_name="username/test-delete-object", + ).store(synapse_client=self.syn) + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I delete using the DockerRepository object + delete(docker_repo, synapse_client=self.syn) + + # THEN the repository should be deleted + with pytest.raises(SynapseHTTPError) as e: + DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( + e.value + ) From 123176b42fa31f1c4ecb8579c6ed97281c6b994a Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Wed, 18 Feb 2026 15:14:28 -0500 Subject: [PATCH 12/18] add to async test --- .../async/test_delete_operations_async.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py index ddb9c9ac6..70d5d5879 100644 --- a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py @@ -7,7 +7,7 @@ from synapseclient import Synapse from synapseclient.core import utils from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import File, Project, RecordSet +from synapseclient.models import DockerRepository, File, Project, RecordSet from synapseclient.operations import delete_async @@ -491,3 +491,45 @@ async def test_delete_file_with_version_number_none_no_warning( assert not any( "Version conflict" in record.message for record in caplog.records ) + + async def test_delete_docker_repo_by_id_string_async( + self, project_model: Project + ) -> None: + """Test deleting a Docker repository using a string ID.""" + # GIVEN a Docker repository stored in synapse + docker_repo = await DockerRepository( + parent_id=project_model.id, + repository_name="username/test-delete-string", + ).store_async(synapse_client=self.syn) + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I delete using string ID + await delete_async(docker_repo.id, synapse_client=self.syn) + + # THEN the repository should be deleted + with pytest.raises(SynapseHTTPError) as e: + DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( + e.value + ) + + async def test_delete_docker_repo_by_objec_async( + self, project_model: Project + ) -> None: + """Test deleting a Docker repository using a DockerRepository object.""" + # GIVEN a Docker repository stored in synapse + docker_repo = await DockerRepository( + parent_id=project_model.id, + repository_name="username/test-delete-object", + ).store_async(synapse_client=self.syn) + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I delete using the DockerRepository object + await delete_async(docker_repo, synapse_client=self.syn) + + # THEN the repository should be deleted + with pytest.raises(SynapseHTTPError) as e: + DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( + e.value + ) From fe0f7062aca9897531483bd8a71c00c14676b409 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Wed, 18 Feb 2026 15:19:27 -0500 Subject: [PATCH 13/18] add test for factory get --- .../async/test_factory_operations_async.py | 27 +++++++++++++++++++ .../synchronous/test_factory_operations.py | 26 ++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/integration/synapseclient/operations/async/test_factory_operations_async.py b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py index bc8e04db4..b68e4f0aa 100644 --- a/tests/integration/synapseclient/operations/async/test_factory_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py @@ -15,6 +15,7 @@ ColumnType, Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -845,3 +846,29 @@ async def test_get_async_validation_errors(self) -> None: ValueError, match="Must specify either synapse_id or entity_name" ): await get_async(synapse_client=self.syn) + + async def test_get_docker_repo_by_id(self, project_model: Project) -> None: + """Test retrieving a Docker repository using get factory function.""" + # GIVEN a Docker repository exists + docker_repo = await DockerRepository( + parent_id=project_model.id, + repository_name="username/test-get-factory", + name="Test Factory Repo", + description="Testing get factory", + ).store_async(synapse_client=self.syn) + + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I retrieve it using the get factory function + retrieved = await get_async(synapse_id=docker_repo.id, synapse_client=self.syn) + + # THEN the correct DockerRepository is returned + assert isinstance(retrieved, DockerRepository) + assert retrieved.id == docker_repo.id + assert retrieved.repository_name == "username/test-get-factory" + assert retrieved.name == "Test Factory Repo" + assert retrieved.description == "Testing get factory" + assert retrieved.parent_id == project_model.id + assert retrieved.etag is not None + assert retrieved.created_on is not None + assert retrieved.is_managed == False diff --git a/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py index a6ead3f0e..aa1563262 100644 --- a/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py +++ b/tests/integration/synapseclient/operations/synchronous/test_factory_operations.py @@ -15,6 +15,7 @@ ColumnType, Dataset, DatasetCollection, + DockerRepository, EntityView, File, Folder, @@ -793,3 +794,28 @@ def test_get_validation_errors(self) -> None: ValueError, match="Must specify either synapse_id or entity_name" ): get(synapse_client=self.syn) + + def test_get_docker_repo_by_id(self, project_model: Project) -> None: + """Test retrieving a Docker repository using get factory function.""" + # GIVEN a Docker repository exists + docker_repo = DockerRepository( + parent_id=project_model.id, + repository_name="username/test-get-factory", + name="Test Factory Repo", + description="Testing get factory", + ).store(synapse_client=self.syn) + self.schedule_for_cleanup(docker_repo.id) + + # WHEN I retrieve it using the get factory function + retrieved = get(synapse_id=docker_repo.id, synapse_client=self.syn) + + # THEN the correct DockerRepository is returned + assert isinstance(retrieved, DockerRepository) + assert retrieved.id == docker_repo.id + assert retrieved.repository_name == "username/test-get-factory" + assert retrieved.name == "Test Factory Repo" + assert retrieved.description == "Testing get factory" + assert retrieved.parent_id == project_model.id + assert retrieved.etag is not None + assert retrieved.created_on is not None + assert retrieved.is_managed == False From 848200252d84975f84598c4b5e639133266a6dc5 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Thu, 19 Feb 2026 16:55:07 -0500 Subject: [PATCH 14/18] fix test --- .../models/async/test_docker_async.py | 103 +++++++------ .../models/synchronous/test_docker.py | 135 ------------------ .../async/test_delete_operations_async.py | 4 +- 3 files changed, 65 insertions(+), 177 deletions(-) delete mode 100644 tests/integration/synapseclient/models/synchronous/test_docker.py diff --git a/tests/integration/synapseclient/models/async/test_docker_async.py b/tests/integration/synapseclient/models/async/test_docker_async.py index ef95b0ac2..e61c0e70c 100644 --- a/tests/integration/synapseclient/models/async/test_docker_async.py +++ b/tests/integration/synapseclient/models/async/test_docker_async.py @@ -12,43 +12,62 @@ class TestDockerRepositoryAsync: """Async integration tests for DockerRepository.""" - @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 + @pytest.fixture(scope="class") + async def readonly_docker_repo( + self, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, + ) -> DockerRepository: + """Class-scoped fixture for read-only tests. Do not modify or delete.""" + project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( + synapse_client=syn + ) + schedule_for_cleanup(project.id) + + docker_repo = DockerRepository( + parent_id=project.id, repository_name="username/test-async-readonly" + ) + await docker_repo.store_async(synapse_client=syn) + schedule_for_cleanup(docker_repo.id) + return docker_repo @pytest.fixture(scope="function") - async def test_docker_repo( - self, schedule_for_cleanup: Callable[..., None] + async def mutable_docker_repo( + self, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, ) -> DockerRepository: - """Create a test docker repository for testing.""" - # GIVEN a project to work with + """Function-scoped fixture for tests that modify or delete the repo.""" project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) - self.schedule_for_cleanup(project.id) + schedule_for_cleanup(project.id) - # Create a DockerRepository entity docker_repo = DockerRepository( - parent_id=project.id, repository_name="username/test-async" + parent_id=project.id, repository_name="username/test-async-mutable" ) - await docker_repo.store_async(synapse_client=self.syn) + await docker_repo.store_async(synapse_client=syn) schedule_for_cleanup(docker_repo.id) return docker_repo - async def test_get_docker_repo(self, test_docker_repo: DockerRepository) -> None: + async def test_get_docker_repo( + self, readonly_docker_repo: DockerRepository, syn: Synapse + ) -> None: """Test retrieving a Docker repository by ID (async).""" # GIVEN an existing Docker repository # WHEN we retrieve it by ID retrieved_docker_repo = await DockerRepository( - id=test_docker_repo.id, - ).get_async(synapse_client=self.syn) + id=readonly_docker_repo.id, + ).get_async(synapse_client=syn) # THEN the retrieved DockerRepository should match the created one - assert retrieved_docker_repo.id == test_docker_repo.id - assert retrieved_docker_repo.repository_name == test_docker_repo.repository_name - assert retrieved_docker_repo.parent_id == test_docker_repo.parent_id + assert retrieved_docker_repo.id == readonly_docker_repo.id + assert ( + retrieved_docker_repo.repository_name + == readonly_docker_repo.repository_name + ) + assert retrieved_docker_repo.parent_id == readonly_docker_repo.parent_id # Metadata fields (set by Synapse on creation) assert retrieved_docker_repo.etag is not None @@ -64,22 +83,22 @@ async def test_get_docker_repo(self, test_docker_repo: DockerRepository) -> None == "org.sagebionetworks.repo.model.docker.DockerRepository" ) - async def test_get_docker_repo_missing_id_raises_error(self) -> None: + async def test_get_docker_repo_missing_id_raises_error(self, syn: Synapse) -> None: """Test that get_async() raises ValueError when neither id nor repository_name is set.""" docker_repo = DockerRepository() with pytest.raises( ValueError, match="must have either an id or repository_name" ): - await docker_repo.get_async(synapse_client=self.syn) + await docker_repo.get_async(synapse_client=syn) async def test_get_docker_repo_with_optional_fields( - self, schedule_for_cleanup: Callable[..., None] + self, schedule_for_cleanup: Callable[..., None], syn: Synapse ) -> None: """Test retrieving a Docker repository with all optional fields set (async).""" # GIVEN a project and DockerRepository with all fields project = await Project(name=f"test_project_{uuid.uuid4()}").store_async( - synapse_client=self.syn + synapse_client=syn ) schedule_for_cleanup(project.id) @@ -88,12 +107,12 @@ async def test_get_docker_repo_with_optional_fields( repository_name="username/test-async-optional", name="My Test Repo Async", description="A test repository with all fields (async)", - ).store_async(synapse_client=self.syn) + ).store_async(synapse_client=syn) schedule_for_cleanup(docker_repo.id) # WHEN we retrieve it retrieved = await DockerRepository(id=docker_repo.id).get_async( - synapse_client=self.syn + synapse_client=syn ) # THEN optional fields should be preserved @@ -101,43 +120,47 @@ async def test_get_docker_repo_with_optional_fields( assert retrieved.description == "A test repository with all fields (async)" async def test_update_docker_repo_description( - self, test_docker_repo: DockerRepository + self, mutable_docker_repo: DockerRepository, syn: Synapse ) -> None: """Test updating the description of a Docker repository (async).""" # GIVEN an existing Docker repository - original_description = test_docker_repo.description + original_description = mutable_docker_repo.description # WHEN we update the description new_description = "Updated description for testing (async)" - test_docker_repo.description = new_description - updated_repo = await test_docker_repo.store_async(synapse_client=self.syn) + mutable_docker_repo.description = new_description + updated_repo = await mutable_docker_repo.store_async(synapse_client=syn) # THEN the description should be updated, and other fields should remain unchanged assert updated_repo.description == new_description - assert updated_repo.id == test_docker_repo.id - assert updated_repo.repository_name == test_docker_repo.repository_name - assert updated_repo.parent_id == test_docker_repo.parent_id + assert updated_repo.id == mutable_docker_repo.id + assert updated_repo.repository_name == mutable_docker_repo.repository_name + assert updated_repo.parent_id == mutable_docker_repo.parent_id # Note: etag, modified_on may change on update - assert updated_repo.created_on == test_docker_repo.created_on - assert updated_repo.created_by == test_docker_repo.created_by + assert updated_repo.created_on == mutable_docker_repo.created_on + assert updated_repo.created_by == mutable_docker_repo.created_by - async def test_create_docker_repo_without_parent_raises_error(self) -> None: + async def test_create_docker_repo_without_parent_raises_error( + self, syn: Synapse + ) -> None: """Test that creating a Docker repo without parent_id raises error (async).""" docker_repo = DockerRepository(repository_name="username/test-async-no-parent") with pytest.raises(ValueError, match="parent_id"): - await docker_repo.store_async(synapse_client=self.syn) + await docker_repo.store_async(synapse_client=syn) - async def test_delete_docker_repo(self, test_docker_repo: DockerRepository) -> None: + async def test_delete_docker_repo( + self, mutable_docker_repo: DockerRepository, syn: Synapse + ) -> None: """Test deleting a Docker repository (async).""" # GIVEN an existing Docker repository - repo_id = test_docker_repo.id + repo_id = mutable_docker_repo.id # WHEN we delete it - await test_docker_repo.delete_async(synapse_client=self.syn) + await mutable_docker_repo.delete_async(synapse_client=syn) # THEN it should no longer be retrievable with pytest.raises( Exception, match=f"404 Client Error: Entity {repo_id} is in trash can" ): - await DockerRepository(id=repo_id).get_async(synapse_client=self.syn) + await DockerRepository(id=repo_id).get_async(synapse_client=syn) diff --git a/tests/integration/synapseclient/models/synchronous/test_docker.py b/tests/integration/synapseclient/models/synchronous/test_docker.py deleted file mode 100644 index ba790709a..000000000 --- a/tests/integration/synapseclient/models/synchronous/test_docker.py +++ /dev/null @@ -1,135 +0,0 @@ -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.models import DockerRepository, Project - - -class TestDockerRepository: - @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 - - @pytest.fixture(scope="function") - def test_docker_repo( - self, schedule_for_cleanup: Callable[..., None] - ) -> DockerRepository: - """Create a test docker repository for testing.""" - # GIVEN a project to work with - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - self.schedule_for_cleanup(project.id) - - # Create a DockerRepository entity - docker_repo = DockerRepository( - parent_id=project.id, repository_name="username/test" - ) - schedule_for_cleanup(project.id) - docker_repo.store(synapse_client=self.syn) - return docker_repo - - def test_get_docker_repo(self, test_docker_repo: DockerRepository) -> None: - # GIVEN a project to work with - retrieved_docker_repo = DockerRepository( - id=test_docker_repo.id, - ).get(synapse_client=self.syn) - - # THEN the retrieved DockerRepository should match the created one - assert retrieved_docker_repo.id == test_docker_repo.id - assert retrieved_docker_repo.repository_name == test_docker_repo.repository_name - assert retrieved_docker_repo.parent_id == test_docker_repo.parent_id - - # Metadata fields (set by Synapse on creation) - assert retrieved_docker_repo.etag is not None - assert retrieved_docker_repo.created_on is not None - assert retrieved_docker_repo.modified_on is not None - assert retrieved_docker_repo.created_by is not None - assert retrieved_docker_repo.modified_by is not None - - # Repository type - assert retrieved_docker_repo.is_managed == False # External repo - assert ( - retrieved_docker_repo.concrete_type - == "org.sagebionetworks.repo.model.docker.DockerRepository" - ) - - def test_get_docker_repo_missing_id_raises_error(self) -> None: - """Test that get() raises ValueError when neither id nor repository_name is set.""" - docker_repo = DockerRepository() - - with pytest.raises( - ValueError, match="must have either an id or repository_name" - ): - docker_repo.get(synapse_client=self.syn) - - def test_get_docker_repo_with_optional_fields( - self, schedule_for_cleanup: Callable[..., None] - ) -> None: - """Test retrieving a Docker repository with all optional fields set.""" - # GIVEN a project and DockerRepository with all fields - project = Project(name=f"test_project_{uuid.uuid4()}").store( - synapse_client=self.syn - ) - schedule_for_cleanup(project.id) - - docker_repo = DockerRepository( - parent_id=project.id, - repository_name="username/test", - name="My Test Repo", - description="A test repository with all fields", - ).store(synapse_client=self.syn) - - # WHEN we retrieve it - retrieved = DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) - - # THEN optional fields should be preserved - assert retrieved.name == "My Test Repo" - assert retrieved.description == "A test repository with all fields" - - def test_update_docker_repo_description( - self, test_docker_repo: DockerRepository - ) -> None: - """Test updating the description of a Docker repository.""" - # GIVEN an existing Docker repository - original_description = test_docker_repo.description - - # WHEN we update the description - new_description = "Updated description for testing" - test_docker_repo.description = new_description - updated_repo = test_docker_repo.store(synapse_client=self.syn) - - # THEN the description should be updated, and other fields should remain unchanged - assert updated_repo.description == new_description - assert updated_repo.id == test_docker_repo.id - assert updated_repo.repository_name == test_docker_repo.repository_name - assert updated_repo.parent_id == test_docker_repo.parent_id - assert updated_repo.etag == test_docker_repo.etag - assert updated_repo.created_on == test_docker_repo.created_on - assert updated_repo.modified_on == test_docker_repo.modified_on - assert updated_repo.created_by == test_docker_repo.created_by - assert updated_repo.modified_by == test_docker_repo.modified_by - - def test_create_docker_repo_without_parent_raises_error(self) -> None: - """Test that creating a Docker repo without parent_id raises error.""" - docker_repo = DockerRepository(repository_name="username/test") - - with pytest.raises(ValueError, match="parent_id"): - docker_repo.store(synapse_client=self.syn) - - def test_delete_docker_repo(self, test_docker_repo: DockerRepository) -> None: - """Test deleting a Docker repository.""" - # GIVEN an existing Docker repository - repo_id = test_docker_repo.id - - # WHEN we delete it - test_docker_repo.delete(synapse_client=self.syn) - - # THEN it should no longer be retrievable - with pytest.raises( - Exception, match=f"404 Client Error: Entity {repo_id} is in trash can" - ): - DockerRepository(id=repo_id).get(synapse_client=self.syn) diff --git a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py index 70d5d5879..ca02701a9 100644 --- a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py @@ -508,7 +508,7 @@ async def test_delete_docker_repo_by_id_string_async( # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: - DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + await DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( e.value ) @@ -529,7 +529,7 @@ async def test_delete_docker_repo_by_objec_async( # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: - DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + await DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( e.value ) From 3d5aa4b3e3ffdc7bc535093523d6c722eb1eb15e Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 20 Feb 2026 10:07:05 -0500 Subject: [PATCH 15/18] update to use schedule_for_cleanup directly --- .../async/test_factory_operations_async.py | 6 +- .../synchronous/test_delete_operations.py | 509 ------------------ 2 files changed, 4 insertions(+), 511 deletions(-) delete mode 100644 tests/integration/synapseclient/operations/synchronous/test_delete_operations.py diff --git a/tests/integration/synapseclient/operations/async/test_factory_operations_async.py b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py index b68e4f0aa..2eba51bbc 100644 --- a/tests/integration/synapseclient/operations/async/test_factory_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_factory_operations_async.py @@ -847,7 +847,9 @@ async def test_get_async_validation_errors(self) -> None: ): await get_async(synapse_client=self.syn) - async def test_get_docker_repo_by_id(self, project_model: Project) -> None: + async def test_get_docker_repo_by_id( + self, project_model: Project, schedule_for_cleanup: Callable[..., None] + ) -> None: """Test retrieving a Docker repository using get factory function.""" # GIVEN a Docker repository exists docker_repo = await DockerRepository( @@ -857,7 +859,7 @@ async def test_get_docker_repo_by_id(self, project_model: Project) -> None: description="Testing get factory", ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(docker_repo.id) + schedule_for_cleanup(docker_repo.id) # WHEN I retrieve it using the get factory function retrieved = await get_async(synapse_id=docker_repo.id, synapse_client=self.syn) diff --git a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py b/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py deleted file mode 100644 index c344fcdcb..000000000 --- a/tests/integration/synapseclient/operations/synchronous/test_delete_operations.py +++ /dev/null @@ -1,509 +0,0 @@ -"""Integration tests for delete operations synchronous.""" -import uuid -from typing import Callable - -import pytest - -from synapseclient import Synapse -from synapseclient.core import utils -from synapseclient.core.exceptions import SynapseHTTPError -from synapseclient.models import DockerRepository, File, Project, RecordSet -from synapseclient.operations import delete - - -class TestDeleteOperations: - """Tests for the delete factory function (synchronous).""" - - @pytest.fixture(autouse=True, scope="function") - def init( - self, syn_with_logger: Synapse, schedule_for_cleanup: Callable[..., None] - ) -> None: - self.syn = syn_with_logger - self.schedule_for_cleanup = schedule_for_cleanup - - def test_delete_file_by_id_string(self, project_model: Project) -> None: - """Test deleting a file using a string ID.""" - # GIVEN a file stored in synapse - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file for deletion", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I delete the file using string ID - delete(file.id, synapse_client=self.syn) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {file.id} is in trash can." in str(e.value) - - def test_delete_file_by_object(self, project_model: Project) -> None: - """Test deleting a file using a File object.""" - # GIVEN a file stored in synapse - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file for deletion", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.id is not None - - # WHEN I delete the file using File object - delete(file, synapse_client=self.syn) - - # THEN I expect the file to be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {file.id} is in trash can." in str(e.value) - - def test_delete_file_specific_version_with_version_param( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test deleting a specific version using version parameter (highest priority).""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # Create version 3 - file.description = "Test file version 3" - file = file.store(synapse_client=self.syn) - assert file.version_number == 3 - - # WHEN I delete version 2 using version parameter with version_only=True - file_v2 = File(id=file.id, version_number=999) # Set wrong version on entity - - # Capture logs - caplog.clear() - delete( - file_v2, - version=2, # This should take precedence over entity's version_number - version_only=True, - synapse_client=self.syn, - ) - - # Check that warning was logged - assert any("Version conflict" in record.message for record in caplog.records) - assert any( - "version' parameter (2)" in record.message for record in caplog.records - ) - - # THEN version 2 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 2" in str(e.value) - - # AND version 1 and 3 should still exist - file_v1 = File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert file_v1.version_number == 1 - - file_v3 = File(id=file.id, version_number=3).get(synapse_client=self.syn) - assert file_v3.version_number == 3 - - def test_delete_file_specific_version_with_entity_version_number( - self, project_model: Project - ) -> None: - """Test deleting a specific version using entity's version_number attribute.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # WHEN I delete version 1 using entity's version_number - file_v1 = File(id=file.id, version_number=1) - - delete(file_v1, version_only=True, synapse_client=self.syn) - - # THEN version 1 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 1" in str(e.value) - - # AND version 2 should still exist - file_v2 = File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert file_v2.version_number == 2 - - def test_delete_file_specific_version_with_id_string( - self, project_model: Project - ) -> None: - """Test deleting a specific version using ID string with version.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - assert file.version_number == 1 - - # Create version 2 - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - assert file.version_number == 2 - - # WHEN I delete version 1 using string ID with version - delete(f"{file.id}.1", version_only=True, synapse_client=self.syn) - - # THEN version 1 should be deleted - with pytest.raises(SynapseHTTPError) as e: - File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert f"Cannot find a node with id {file.id} and version 1" in str(e.value) - - # AND version 2 should still exist - file_v2 = File(id=file.id, version_number=2).get(synapse_client=self.syn) - assert file_v2.version_number == 2 - - def test_delete_recordset_specific_version(self, project_model: Project) -> None: - """Test deleting a specific version of a RecordSet.""" - # GIVEN a RecordSet with multiple versions - filename1 = utils.make_bogus_uuid_file() - filename2 = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename1) - self.schedule_for_cleanup(filename2) - - record_set = RecordSet( - name=str(uuid.uuid4()), - path=filename1, - description="RecordSet version 1", - parent_id=project_model.id, - upsert_keys=["id"], - ) - record_set = record_set.store(synapse_client=self.syn) - self.schedule_for_cleanup(record_set.id) - assert record_set.version_number == 1 - - # Create version 2 - record_set.path = filename2 - record_set.description = "RecordSet version 2" - record_set = record_set.store(synapse_client=self.syn) - assert record_set.version_number == 2 - - # WHEN I delete version 2 using version parameter - delete( - RecordSet(id=record_set.id, version_number=2), - version_only=True, - synapse_client=self.syn, - ) - - # THEN version 2 should be gone and version 1 should be current - current_record_set = RecordSet(id=record_set.id).get(synapse_client=self.syn) - assert current_record_set.version_number == 1 - assert current_record_set.description == "RecordSet version 1" - - def test_delete_version_only_without_version_raises_error( - self, project_model: Project - ) -> None: - """Test that version_only=True without a version number raises an error.""" - # GIVEN a file without version_number set - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I try to delete with version_only=True but no version - file_no_version = File(id=file.id) - - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete(file_no_version, version_only=True, synapse_client=self.syn) - assert "version_only=True requires a version number" in str(e.value) - - def test_delete_project_ignores_version_parameters( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that deleting a Project ignores version parameters with warning.""" - # GIVEN a project - project = Project( - name=str(uuid.uuid4()), - description="Test project for version parameter", - ) - project = project.store(synapse_client=self.syn) - self.schedule_for_cleanup(project.id) - - # WHEN I try to delete with version_only=True - caplog.clear() - delete(project, version_only=True, synapse_client=self.syn) - - # THEN warnings should be logged - assert any( - "does not support version-specific deletion" in record.message - for record in caplog.records - ) - - # AND the entire project should be deleted - with pytest.raises(SynapseHTTPError) as e: - Project(id=project.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {project.id} is in trash can." in str(e.value) - - def test_delete_invalid_synapse_id_raises_error(self) -> None: - """Test that an invalid Synapse ID raises an error.""" - # WHEN I try to delete with an invalid ID - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete("invalid_id", synapse_client=self.syn) - assert "Invalid Synapse ID: invalid_id" in str(e.value) - - def test_delete_with_dot_notation_without_version_only_raises_error( - self, project_model: Project - ) -> None: - """Test that using dot notation without version_only=True raises an error.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I try to delete with dot notation but without version_only=True - # THEN it should raise ValueError - with pytest.raises(ValueError) as e: - delete(f"{file.id}.1", synapse_client=self.syn) - assert "Deleting a specific version requires version_only=True" in str(e.value) - assert f"delete('{file.id}.1', version_only=True)" in str(e.value) - - def test_version_precedence_version_param_over_entity_attribute( - self, project_model: Project - ) -> None: - """Test that version parameter takes precedence over entity's version_number.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - file.description = "Test file version 3" - file = file.store(synapse_client=self.syn) - - # WHEN I have entity with version_number=1 but pass version=2 - file_entity = File(id=file.id, version_number=1) - - # version=2 should take precedence - delete(file_entity, version=2, version_only=True, synapse_client=self.syn) - - # THEN version 2 should be deleted (not version 1) - with pytest.raises(SynapseHTTPError): - File(id=file.id, version_number=2).get(synapse_client=self.syn) - - # AND version 1 should still exist - file_v1 = File(id=file.id, version_number=1).get(synapse_client=self.syn) - assert file_v1.version_number == 1 - - def test_delete_version_param_without_conflict_no_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning is logged when version parameter is used without conflict.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - # WHEN I delete with version parameter but entity has no version_number set - file_no_version = File(id=file.id) # No version_number attribute set - - caplog.clear() - delete(file_no_version, version=1, version_only=True, synapse_client=self.syn) - - # THEN no version conflict warning should be logged - assert not any( - "Version conflict" in record.message for record in caplog.records - ) - - # AND version 1 should be deleted - with pytest.raises(SynapseHTTPError): - File(id=file.id, version_number=1).get(synapse_client=self.syn) - - def test_delete_folder_with_version_only_logs_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that deleting a Folder with version_only=True logs a warning.""" - from synapseclient.models import Folder - - # GIVEN a folder - folder = Folder( - name=str(uuid.uuid4()), - parent_id=project_model.id, - ) - folder = folder.store(synapse_client=self.syn) - self.schedule_for_cleanup(folder.id) - - # WHEN I try to delete with version_only=True - caplog.clear() - delete(folder, version_only=True, synapse_client=self.syn) - - # THEN warning should be logged - assert any( - "does not support version-specific deletion" in record.message - for record in caplog.records - ) - assert any("Folder" in record.message for record in caplog.records) - - def test_no_warning_when_version_only_false_despite_conflict( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning is logged when version_only=False even with version conflict.""" - # GIVEN a file - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - # WHEN I delete with version parameter but version_only=False - file_entity = File(id=file.id, version_number=999) - - caplog.clear() - delete( - file_entity, - version=1, - version_only=False, # Not deleting specific version - synapse_client=self.syn, - ) - - # THEN no version conflict warning should be logged (since version_only=False) - assert not any( - "Version conflict" in record.message for record in caplog.records - ) - - def test_delete_file_with_version_number_none_no_warning( - self, project_model: Project, caplog: pytest.LogCaptureFixture - ) -> None: - """Test that no warning when entity.version_number is explicitly None.""" - # GIVEN a file with multiple versions - filename = utils.make_bogus_uuid_file() - self.schedule_for_cleanup(filename) - - file = File( - path=filename, - parent_id=project_model.id, - description="Test file version 1", - ) - file = file.store(synapse_client=self.syn) - self.schedule_for_cleanup(file.id) - - file.description = "Test file version 2" - file = file.store(synapse_client=self.syn) - - # WHEN I have entity with version_number=None but pass version parameter - file_entity = File(id=file.id, version_number=None) - - caplog.clear() - delete(file_entity, version=1, version_only=True, synapse_client=self.syn) - - # THEN no conflict warning should be logged (None is not a conflict) - assert not any( - "Version conflict" in record.message for record in caplog.records - ) - - def test_delete_docker_repo_by_id_string(self, project_model: Project) -> None: - """Test deleting a Docker repository using a string ID.""" - # GIVEN a Docker repository stored in synapse - docker_repo = DockerRepository( - parent_id=project_model.id, - repository_name="username/test-delete-string", - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(docker_repo.id) - - # WHEN I delete using string ID - delete(docker_repo.id, synapse_client=self.syn) - - # THEN the repository should be deleted - with pytest.raises(SynapseHTTPError) as e: - DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( - e.value - ) - - def test_delete_docker_repo_by_object(self, project_model: Project) -> None: - """Test deleting a Docker repository using a DockerRepository object.""" - # GIVEN a Docker repository stored in synapse - docker_repo = DockerRepository( - parent_id=project_model.id, - repository_name="username/test-delete-object", - ).store(synapse_client=self.syn) - self.schedule_for_cleanup(docker_repo.id) - - # WHEN I delete using the DockerRepository object - delete(docker_repo, synapse_client=self.syn) - - # THEN the repository should be deleted - with pytest.raises(SynapseHTTPError) as e: - DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) - assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( - e.value - ) From 419abcc82dba1b8132736ebed4fecae67a576c96 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 20 Feb 2026 10:09:15 -0500 Subject: [PATCH 16/18] fix test --- .../async/test_delete_operations_async.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py index ca02701a9..1dc9d061f 100644 --- a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py @@ -493,7 +493,10 @@ async def test_delete_file_with_version_number_none_no_warning( ) async def test_delete_docker_repo_by_id_string_async( - self, project_model: Project + self, + project_model: Project, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, ) -> None: """Test deleting a Docker repository using a string ID.""" # GIVEN a Docker repository stored in synapse @@ -501,10 +504,10 @@ async def test_delete_docker_repo_by_id_string_async( parent_id=project_model.id, repository_name="username/test-delete-string", ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(docker_repo.id) + schedule_for_cleanup(docker_repo.id) # WHEN I delete using string ID - await delete_async(docker_repo.id, synapse_client=self.syn) + await delete_async(docker_repo.id, synapse_client=syn) # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: @@ -514,7 +517,10 @@ async def test_delete_docker_repo_by_id_string_async( ) async def test_delete_docker_repo_by_objec_async( - self, project_model: Project + self, + project_model: Project, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, ) -> None: """Test deleting a Docker repository using a DockerRepository object.""" # GIVEN a Docker repository stored in synapse @@ -522,14 +528,14 @@ async def test_delete_docker_repo_by_objec_async( parent_id=project_model.id, repository_name="username/test-delete-object", ).store_async(synapse_client=self.syn) - self.schedule_for_cleanup(docker_repo.id) + schedule_for_cleanup(docker_repo.id) # WHEN I delete using the DockerRepository object await delete_async(docker_repo, synapse_client=self.syn) # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: - await DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + await DockerRepository(id=docker_repo.id).get(synapse_client=syn) assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( e.value ) From df1e4a16a856d3cb59847d3de6577178571cea26 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 20 Feb 2026 11:37:08 -0500 Subject: [PATCH 17/18] fix unit test --- .../unit/synapseclient/models/async/unit_test_docker_async.py | 3 ++- .../unit/synapseclient/models/synchronous/unit_test_docker.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/synapseclient/models/async/unit_test_docker_async.py b/tests/unit/synapseclient/models/async/unit_test_docker_async.py index e3cda21bb..b1d42dc5f 100644 --- a/tests/unit/synapseclient/models/async/unit_test_docker_async.py +++ b/tests/unit/synapseclient/models/async/unit_test_docker_async.py @@ -263,4 +263,5 @@ async def test_delete_docker(self) -> None: await docker.delete_async(synapse_client=self.syn) # Verify delete was called with the entity ID - mocked_delete.assert_called_once_with(TEST_ID) + + mocked_delete.assert_called_once_with(TEST_ID, synapse_client=self.syn) diff --git a/tests/unit/synapseclient/models/synchronous/unit_test_docker.py b/tests/unit/synapseclient/models/synchronous/unit_test_docker.py index 1632a9a77..48b44996c 100644 --- a/tests/unit/synapseclient/models/synchronous/unit_test_docker.py +++ b/tests/unit/synapseclient/models/synchronous/unit_test_docker.py @@ -263,4 +263,4 @@ def test_delete_docker(self) -> None: docker.delete(synapse_client=self.syn) # Verify delete was called with the entity ID - mocked_delete.assert_called_once_with(TEST_ID) + mocked_delete.assert_called_once_with(TEST_ID, synapse_client=self.syn) From 80e7ac4e5167b6d5e9eca88fc9078f75b288a4a9 Mon Sep 17 00:00:00 2001 From: Lingling Peng Date: Fri, 20 Feb 2026 13:20:43 -0500 Subject: [PATCH 18/18] explicitly call async --- .../operations/async/test_delete_operations_async.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py index 1dc9d061f..6f24d167a 100644 --- a/tests/integration/synapseclient/operations/async/test_delete_operations_async.py +++ b/tests/integration/synapseclient/operations/async/test_delete_operations_async.py @@ -511,7 +511,7 @@ async def test_delete_docker_repo_by_id_string_async( # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: - await DockerRepository(id=docker_repo.id).get(synapse_client=self.syn) + await DockerRepository(id=docker_repo.id).get_async(synapse_client=self.syn) assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( e.value ) @@ -535,7 +535,7 @@ async def test_delete_docker_repo_by_objec_async( # THEN the repository should be deleted with pytest.raises(SynapseHTTPError) as e: - await DockerRepository(id=docker_repo.id).get(synapse_client=syn) + await DockerRepository(id=docker_repo.id).get_async(synapse_client=syn) assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( e.value )