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..c45d61c68 --- /dev/null +++ b/synapseclient/models/docker.py @@ -0,0 +1,445 @@ +"""Docker repository dataclass model for Synapse entities.""" + +import asyncio +from dataclasses import dataclass, field, replace +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.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, +) +from synapseclient.models.services.storable_entity import store_entity +from synapseclient.operations import delete + + +@dataclass() +@async_to_sync +class DockerRepository(DockerRepositorySynchronousProtocol, AccessControllable): + """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.""" + + 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, + 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 + ) + """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], 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. + """ + 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) + self.concrete_type = synapse_entity.get("concreteType", None) + + if set_annotations: + self.annotations = Annotations.from_dict( + synapse_entity.get("annotations", 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, + ) + + await get_from_entity_factory( + entity_to_update=self, + synapse_id_or_path=self.id, + synapse_client=synapse_client, + ) + + 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-user-name/my-repo" + ).store_async() + return docker_repo + + docker_repo = asyncio.run(create_external_docker_repo()) + print(docker_repo.id) + ``` + """ + if not self.parent_id or not self.repository_name: + raise ValueError( + "Creating a new Docker repository requires both parent_id and repository_name." + ) + + if self.has_changed: + entity = await store_entity( + resource=self, + entity=self.to_synapse_request(), + synapse_client=synapse_client, + ) + self.fill_from_dict(synapse_entity=entity, set_annotations=False) + + 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 for deletion. + + 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("Deleting a Docker repository requires an id to be set.") + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: delete( + self.id, + synapse_client=synapse_client, + ), + ) + + 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/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/synapseclient/operations/factory_operations.py b/synapseclient/operations/factory_operations.py index 1c17bd7ff..904bd4295 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, DatasetCollection, 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, 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..e61c0e70c --- /dev/null +++ b/tests/integration/synapseclient/models/async/test_docker_async.py @@ -0,0 +1,166 @@ +"""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(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 mutable_docker_repo( + self, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, + ) -> DockerRepository: + """Function-scoped fixture for tests that modify or delete the repo.""" + 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-mutable" + ) + await docker_repo.store_async(synapse_client=syn) + schedule_for_cleanup(docker_repo.id) + return docker_repo + + 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=readonly_docker_repo.id, + ).get_async(synapse_client=syn) + + # THEN the retrieved DockerRepository should match the created one + 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 + 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, 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=syn) + + async def test_get_docker_repo_with_optional_fields( + 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=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=syn) + schedule_for_cleanup(docker_repo.id) + + # WHEN we retrieve it + retrieved = await DockerRepository(id=docker_repo.id).get_async( + synapse_client=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, mutable_docker_repo: DockerRepository, syn: Synapse + ) -> None: + """Test updating the description of a Docker repository (async).""" + # GIVEN an existing Docker repository + original_description = mutable_docker_repo.description + + # WHEN we update the description + new_description = "Updated description for testing (async)" + 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 == 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 == 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, 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=syn) + + 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 = mutable_docker_repo.id + + # WHEN we delete it + 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=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 ddb9c9ac6..6f24d167a 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,51 @@ 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, + schedule_for_cleanup: Callable[..., None], + syn: Synapse, + ) -> 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) + schedule_for_cleanup(docker_repo.id) + + # WHEN I delete using string ID + await delete_async(docker_repo.id, synapse_client=syn) + + # THEN the repository should be deleted + with pytest.raises(SynapseHTTPError) as e: + 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 + ) + + async def test_delete_docker_repo_by_objec_async( + 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 + docker_repo = await DockerRepository( + parent_id=project_model.id, + repository_name="username/test-delete-object", + ).store_async(synapse_client=self.syn) + 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_async(synapse_client=syn) + assert f"404 Client Error: Entity {docker_repo.id} is in trash can" in str( + e.value + ) 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..2eba51bbc 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,31 @@ 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, schedule_for_cleanup: Callable[..., None] + ) -> 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) + + 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/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..b1d42dc5f --- /dev/null +++ b/tests/unit/synapseclient/models/async/unit_test_docker_async.py @@ -0,0 +1,267 @@ +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. + # 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 + ): + 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 = 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_annotation + + 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( + 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 = 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 + assert result.annotations == test_annotation + + 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() + + # 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 + 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, 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 new file mode 100644 index 000000000..48b44996c --- /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, synapse_client=self.syn)