Repository: globus/globus-sdk-python

Issue:
Add support for new API for setting endpoints to managed
Transfer 7.0 has added an API which lets users who do not have Administrator on an endpoint to set endpoints to Managed via `PUT /endpoint/<uuid>/subscription`

I'm not sure if this docs for this are live yet.
We need to get clarity on the API a little and add a `TransferClient` method for this.

Discussion:

Patch:
diff --git a/changelog.d/20241004_135423_sirosen.rst b/changelog.d/20241004_135423_sirosen.rst
new file mode 100644
index 00000000..6e42accf
--- /dev/null
+++ b/changelog.d/20241004_135423_sirosen.rst
@@ -0,0 +1,4 @@
+Added
+~~~~~
+
+- Add ``TransferClient.set_subscription_id`` (:pr:`NUMBER`)
diff --git a/src/globus_sdk/services/transfer/client.py b/src/globus_sdk/services/transfer/client.py
index 6b351152..a0b9144d 100644
--- a/src/globus_sdk/services/transfer/client.py
+++ b/src/globus_sdk/services/transfer/client.py
@@ -274,6 +274,65 @@ class TransferClient(client.BaseClient):
         log.info(f"TransferClient.update_endpoint({endpoint_id}, ...)")
         return self.put(f"endpoint/{endpoint_id}", data=data, query_params=query_params)
 
+    def set_subscription_id(
+        self,
+        collection_id: UUIDLike,
+        subscription_id: UUIDLike | Literal["DEFAULT"] | None,
+    ) -> response.GlobusHTTPResponse:
+        """
+        Set the ``subscription_id`` on a mapped collection.
+        This is primarily used for subscription management on Globus Connect Personal.
+
+        This operation requires membership in a Globus subscription group and
+        has authorization requirements which depend upon the caller's roles on
+        the subscription group and the endpoint or collection.
+
+        Guest Collections inherit the subscriptions of their Mapped Collections and
+        cannot have a ``subscription_id`` directly set in this way.
+
+        :param collection_id: The collection ID which is having its subscription set.
+        :param subscription_id: The ID of the subscription to assign, the special
+            string ``"DEFAULT"``, or ``None``.
+
+        .. note::
+
+            Setting ``subscription_id="DEFAULT"`` results in the service choosing your
+            subscription ID, but requires that you only have one subscription.
+            If you have multiple subscriptions, using ``"DEFAULT"`` will result in an
+            error.
+
+            Setting ``subscription_id=None`` clears any existing subscription from the
+            collection.
+
+        .. tab-set::
+
+            .. tab-item:: Example Usage
+
+                .. code-block:: python
+
+                    import globus_sdk
+
+                    MY_SUBSCRIPTION_ID = "..."
+                    LOCAL_GCP = globus_sdk.LocalGlobusConnectPersonal()
+
+                    tc = globus_sdk.TransferClient(...)
+                    tc.endpoint_set_subscription_id(
+                        LOCAL_GCP.endpoint_id,
+                        MY_SUBSCRIPTION_ID,
+                    )
+
+            .. tab-item:: API Info
+
+                ``PUT /endpoint/<collection_id>/subscription``
+
+                .. extdoclink:: Associate a Globus Connect Personal Mapped Collection with a Subscription
+                    :ref: transfer/gcp_management/#associate_collection_subscription
+        """  # noqa: E501
+        return self.put(
+            f"/endpoint/{collection_id}/subscription",
+            data={"subscription_id": subscription_id},
+        )
+
     def create_endpoint(self, data: dict[str, t.Any]) -> response.GlobusHTTPResponse:
         """
         .. warning::


Test patch:
diff --git a/src/globus_sdk/_testing/data/transfer/_common.py b/src/globus_sdk/_testing/data/transfer/_common.py
index 8c8f2186..424c99ef 100644
--- a/src/globus_sdk/_testing/data/transfer/_common.py
+++ b/src/globus_sdk/_testing/data/transfer/_common.py
@@ -8,3 +8,4 @@ def _as_uuid(s: str) -> str:
 SUBMISSION_ID = _as_uuid("submission_id")
 ENDPOINT_ID = _as_uuid("endpoint_id")
 TASK_ID = _as_uuid("task_id")
+SUBSCRIPTION_ID = _as_uuid("subscription_id")
diff --git a/src/globus_sdk/_testing/data/transfer/set_subscription_id.py b/src/globus_sdk/_testing/data/transfer/set_subscription_id.py
new file mode 100644
index 00000000..57cd9b22
--- /dev/null
+++ b/src/globus_sdk/_testing/data/transfer/set_subscription_id.py
@@ -0,0 +1,55 @@
+import uuid
+
+from globus_sdk._testing.models import RegisteredResponse, ResponseSet
+
+from ._common import ENDPOINT_ID, SUBSCRIPTION_ID
+
+OTHER_SUBSCRIPTION_ID = str(uuid.UUID(int=10))
+
+RESPONSES = ResponseSet(
+    metadata={"endpoint_id": ENDPOINT_ID},
+    default=RegisteredResponse(
+        service="transfer",
+        method="PUT",
+        path=f"/endpoint/{ENDPOINT_ID}/subscription",
+        json={
+            "DATA_TYPE": "result",
+            "code": "Updated",
+            "message": "Endpoint updated successfully",
+            "request_id": "dWTZZe17L",
+            "resource": f"/endpoint/{ENDPOINT_ID}/subscription",
+        },
+    ),
+    not_found=RegisteredResponse(
+        service="transfer",
+        method="PUT",
+        path=f"/endpoint/{ENDPOINT_ID}/subscription",
+        status=404,
+        json={
+            "code": "EndpointNotFound",
+            "message": f"No such endpoint '{ENDPOINT_ID}'",
+            "request_id": "BHI2BHt8N",
+            "resource": f"/endpoint/{ENDPOINT_ID}/subscription",
+        },
+    ),
+    multi_subscriber_cannot_use_default=RegisteredResponse(
+        service="transfer",
+        method="PUT",
+        path=f"/endpoint/{ENDPOINT_ID}/subscription",
+        status=400,
+        json={
+            "code": "BadRequest",
+            "message": (
+                "Please specify the subscription ID to use. "
+                "You currently have access to: "
+                f"{SUBSCRIPTION_ID}, {OTHER_SUBSCRIPTION_ID}"
+            ),
+            "request_id": "H1dFNg6QB",
+            "resource": f"/endpoint/{ENDPOINT_ID}/subscription",
+        },
+        metadata={
+            "endpoint_id": ENDPOINT_ID,
+            "subscription_ids": [SUBSCRIPTION_ID, OTHER_SUBSCRIPTION_ID],
+        },
+    ),
+)
diff --git a/tests/functional/services/transfer/test_set_subscription_id.py b/tests/functional/services/transfer/test_set_subscription_id.py
new file mode 100644
index 00000000..2676baa8
--- /dev/null
+++ b/tests/functional/services/transfer/test_set_subscription_id.py
@@ -0,0 +1,39 @@
+import pytest
+
+import globus_sdk
+from globus_sdk._testing import load_response
+
+
+def test_set_subscription_id(client):
+    meta = load_response(client.set_subscription_id).metadata
+    epid = meta["endpoint_id"]
+
+    res = client.set_subscription_id(epid, "DEFAULT")
+    assert res["code"] == "Updated"
+    assert res["message"] == "Endpoint updated successfully"
+
+
+def test_set_subscription_id_fails_notfound(client):
+    meta = load_response(client.set_subscription_id, case="not_found").metadata
+    epid = meta["endpoint_id"]
+
+    with pytest.raises(globus_sdk.TransferAPIError) as excinfo:
+        client.set_subscription_id(epid, "DEFAULT")
+
+    assert excinfo.value.code == "EndpointNotFound"
+    assert excinfo.value.messages == [f"No such endpoint '{epid}'"]
+
+
+def test_set_subscription_id_fails_multi(client):
+    meta = load_response(
+        client.set_subscription_id, case="multi_subscriber_cannot_use_default"
+    ).metadata
+    epid = meta["endpoint_id"]
+
+    with pytest.raises(globus_sdk.TransferAPIError) as excinfo:
+        client.set_subscription_id(epid, "DEFAULT")
+
+    assert excinfo.value.code == "BadRequest"
+    assert len(excinfo.value.messages) == 1
+    message = excinfo.value.messages[0]
+    assert message.startswith("Please specify the subscription ID to use.")