Repository: huggingface/huggingface_hub

Issue:
Add `duplicate_space` endpoint
Currently kinda unofficial. Scripts to duplicate Spaces have to do it "manually" at the moment (requests.post(url) + hf_raise_for_status). Let's make it a clearly supported endpoint.


Discussion:

Resolving patch:
diff --git a/src/huggingface_hub/__init__.py b/src/huggingface_hub/__init__.py
--- a/src/huggingface_hub/__init__.py
+++ b/src/huggingface_hub/__init__.py
@@ -130,6 +130,7 @@
         "delete_repo",
         "delete_space_secret",
         "delete_tag",
+        "duplicate_space",
         "edit_discussion_comment",
         "get_dataset_tags",
         "get_discussion_details",
@@ -388,6 +389,7 @@ def __dir__():
         delete_repo,  # noqa: F401
         delete_space_secret,  # noqa: F401
         delete_tag,  # noqa: F401
+        duplicate_space,  # noqa: F401
         edit_discussion_comment,  # noqa: F401
         get_dataset_tags,  # noqa: F401
         get_discussion_details,  # noqa: F401
diff --git a/src/huggingface_hub/hf_api.py b/src/huggingface_hub/hf_api.py
--- a/src/huggingface_hub/hf_api.py
+++ b/src/huggingface_hub/hf_api.py
@@ -245,6 +245,7 @@ class RepoUrl(str):
     compatibility. At initialization, the URL is parsed to populate properties:
     - endpoint (`str`)
     - namespace (`Optional[str]`)
+    - repo_name (`str`)
     - repo_id (`str`)
     - repo_type (`Literal["model", "dataset", "space"]`)
     - url (`str`)
@@ -288,6 +289,7 @@ def __init__(self, url: Any, endpoint: Optional[str] = None) -> None:
 
         # Populate fields
         self.namespace = namespace
+        self.repo_name = repo_name
         self.repo_id = repo_name if namespace is None else f"{namespace}/{repo_name}"
         self.repo_type = repo_type or REPO_TYPE_MODEL
         self.url = str(self)  # just in case it's needed
@@ -4070,6 +4072,93 @@ def restart_space(self, repo_id: str, *, token: Optional[str] = None) -> SpaceRu
         hf_raise_for_status(r)
         return SpaceRuntime(r.json())
 
+    @validate_hf_hub_args
+    def duplicate_space(
+        self,
+        from_id: str,
+        to_id: Optional[str] = None,
+        *,
+        private: Optional[bool] = None,
+        token: Optional[str] = None,
+        exist_ok: bool = False,
+    ) -> str:
+        """Duplicate a Space.
+
+        Programmatically duplicate a Space. The new Space will be created in your account and will be in the same state
+        as the original Space (running or paused). You can duplicate a Space no matter the current state of a Space.
+
+        Args:
+            from_id (`str`):
+                ID of the Space to duplicate. Example: `"pharma/CLIP-Interrogator"`.
+            to_id (`str`, *optional*):
+                ID of the new Space. Example: `"dog/CLIP-Interrogator"`. If not provided, the new Space will have the same
+                name as the original Space, but in your account.
+            private (`bool`, *optional*):
+                Whether the new Space should be private or not. Defaults to the same privacy as the original Space.
+            token (`str`, *optional*):
+                Hugging Face token. Will default to the locally saved token if not provided.
+            exist_ok (`bool`, *optional*, defaults to `False`):
+                If `True`, do not raise an error if repo already exists.
+
+        Returns:
+            [`RepoUrl`]: URL to the newly created repo. Value is a subclass of `str` containing
+            attributes like `endpoint`, `repo_type` and `repo_id`.
+
+        Raises:
+            - [`HTTPError`](https://2.python-requests.org/en/master/api/#requests.HTTPError)
+              if the HuggingFace API returned an error
+            - [`~utils.RepositoryNotFoundError`]
+              If one of `from_id` or `to_id` cannot be found. This may be because it doesn't exist,
+              or because it is set to `private` and you do not have access.
+
+        Example:
+        ```python
+        >>> from huggingface_hub import duplicate_space
+
+        # Duplicate a Space to your account
+        >>> duplicate_space("multimodalart/dreambooth-training")
+        RepoUrl('https://huggingface.co/spaces/nateraw/dreambooth-training',...)
+
+        # Can set custom destination id and visibility flag.
+        >>> duplicate_space("multimodalart/dreambooth-training", to_id="my-dreambooth", private=True)
+        RepoUrl('https://huggingface.co/spaces/nateraw/my-dreambooth',...)
+        ```
+        """
+        # Parse to_id if provided
+        parsed_to_id = RepoUrl(to_id) if to_id is not None else None
+
+        # Infer target repo_id
+        to_namespace = (  # set namespace manually or default to username
+            parsed_to_id.namespace
+            if parsed_to_id is not None and parsed_to_id.namespace is not None
+            else self.whoami(token)["name"]
+        )
+        to_repo_name = parsed_to_id.repo_name if to_id is not None else RepoUrl(from_id).repo_name  # type: ignore
+
+        # repository must be a valid repo_id (namespace/repo_name).
+        payload: Dict[str, Any] = {"repository": f"{to_namespace}/{to_repo_name}"}
+
+        # private is optional with this endpoint, with None defaulting to the original space's privacy.
+        if private is not None:
+            payload["private"] = private
+
+        r = requests.post(
+            f"{self.endpoint}/api/spaces/{from_id}/duplicate",
+            headers=self._build_hf_headers(token=token, is_write_action=True),
+            json=payload,
+        )
+
+        try:
+            hf_raise_for_status(r)
+        except HTTPError as err:
+            if exist_ok and err.response.status_code == 409:
+                # Repo already exists and `exist_ok=True`
+                pass
+            else:
+                raise
+
+        return RepoUrl(r.json()["url"], endpoint=self.endpoint)
+
     def _build_hf_headers(
         self,
         token: Optional[Union[bool, str]] = None,
@@ -4210,3 +4299,4 @@ def _parse_revision_from_pr_url(pr_url: str) -> str:
 request_space_hardware = api.request_space_hardware
 pause_space = api.pause_space
 restart_space = api.restart_space
+duplicate_space = api.duplicate_space
diff --git a/src/huggingface_hub/utils/_validators.py b/src/huggingface_hub/utils/_validators.py
--- a/src/huggingface_hub/utils/_validators.py
+++ b/src/huggingface_hub/utils/_validators.py
@@ -108,7 +108,7 @@ def _inner_fn(*args, **kwargs):
             zip(signature.parameters, args),  # Args values
             kwargs.items(),  # Kwargs values
         ):
-            if arg_name == "repo_id":
+            if arg_name in ["repo_id", "from_id", "to_id"]:
                 validate_repo_id(arg_value)
 
             elif arg_name == "token" and arg_value is not None:


Test patch:
diff --git a/tests/test_hf_api.py b/tests/test_hf_api.py
--- a/tests/test_hf_api.py
+++ b/tests/test_hf_api.py
@@ -83,6 +83,7 @@
     ENDPOINT_STAGING_BASIC_AUTH,
     FULL_NAME,
     OTHER_TOKEN,
+    OTHER_USER,
     TOKEN,
     USER,
 )
@@ -2533,3 +2534,44 @@ def test_repo_url_canonical_dataset(self):
                 url = RepoUrl(_id)
                 self.assertEqual(url.repo_id, "squad")
                 self.assertEqual(url.repo_type, "dataset")
+
+
+class HfApiDuplicateSpaceTest(HfApiCommonTestWithLogin):
+    @retry_endpoint
+    def test_duplicate_space_success(self) -> None:
+        """Check `duplicate_space` works."""
+        from_repo_name = space_repo_name("original_repo_name")
+        from_repo_id = self._api.create_repo(
+            repo_id=from_repo_name,
+            repo_type="space",
+            space_sdk="static",
+            token=OTHER_TOKEN,
+        ).repo_id
+        self._api.upload_file(
+            path_or_fileobj=b"data",
+            path_in_repo="temp/new_file.md",
+            repo_id=from_repo_id,
+            repo_type="space",
+            token=OTHER_TOKEN,
+        )
+
+        to_repo_id = self._api.duplicate_space(from_repo_id).repo_id
+
+        self.assertEqual(to_repo_id, f"{USER}/{from_repo_name}")
+        self.assertEqual(
+            self._api.list_repo_files(repo_id=from_repo_id, repo_type="space"),
+            [".gitattributes", "README.md", "index.html", "style.css", "temp/new_file.md"],
+        )
+        self.assertEqual(
+            self._api.list_repo_files(repo_id=to_repo_id, repo_type="space"),
+            self._api.list_repo_files(repo_id=from_repo_id, repo_type="space"),
+        )
+
+        self._api.delete_repo(repo_id=from_repo_id, repo_type="space", token=OTHER_TOKEN)
+        self._api.delete_repo(repo_id=to_repo_id, repo_type="space")
+
+    def test_duplicate_space_from_missing_repo(self) -> None:
+        """Check `duplicate_space` fails when the from_repo doesn't exist."""
+
+        with self.assertRaises(RepositoryNotFoundError):
+            self._api.duplicate_space(f"{OTHER_USER}/repo_that_does_not_exist")