diff --git a/docs/source/en/_redirects.yml b/docs/source/en/_redirects.yml index 1f29eed091..cfc5183087 100644 --- a/docs/source/en/_redirects.yml +++ b/docs/source/en/_redirects.yml @@ -12,3 +12,6 @@ package_reference/inference_api: package_reference/inference_client # Alias for hf-transfer description hf_transfer: package_reference/environment_variables#hfhubenablehftransfer + +# Rename webhooks_server to webhooks +guides/webhooks_server: guides/webhooks \ No newline at end of file diff --git a/docs/source/en/_toctree.yml b/docs/source/en/_toctree.yml index 36efe4cacd..9bb6c087dc 100644 --- a/docs/source/en/_toctree.yml +++ b/docs/source/en/_toctree.yml @@ -38,8 +38,8 @@ title: Manage your Space - local: guides/integrations title: Integrate a library - - local: guides/webhooks_server - title: Webhooks server + - local: guides/webhooks + title: Webhooks - title: "Conceptual guides" sections: - local: concepts/git_vs_http diff --git a/docs/source/en/guides/webhooks_server.md b/docs/source/en/guides/webhooks.md similarity index 73% rename from docs/source/en/guides/webhooks_server.md rename to docs/source/en/guides/webhooks.md index 8109ddef36..3d9856c920 100644 --- a/docs/source/en/guides/webhooks_server.md +++ b/docs/source/en/guides/webhooks.md @@ -2,15 +2,91 @@ rendered properly in your Markdown viewer. --> -# Webhooks Server +# Webhooks -Webhooks are a foundation for MLOps-related features. They allow you to listen for new changes on specific repos or to -all repos belonging to particular users/organizations you're interested in following. This guide will explain how to -leverage `huggingface_hub` to create a server listening to webhooks and deploy it to a Space. It assumes you are -familiar with the concept of webhooks on the Huggingface Hub. To learn more about webhooks themselves, you can read -this [guide](https://huggingface.co/docs/hub/webhooks) first. +Webhooks are a foundation for MLOps-related features. They allow you to listen for new changes on specific repos or to all repos belonging to particular users/organizations you're interested in following. This guide will first explain how to manage webhooks programmatically. Then we'll see how to leverage `huggingface_hub` to create a server listening to webhooks and deploy it to a Space. -The base class that we will use in this guide is [`WebhooksServer`]. It is a class for easily configuring a server that +This guide assumes you are familiar with the concept of webhooks on the Huggingface Hub. To learn more about webhooks themselves, you should read this [guide](https://huggingface.co/docs/hub/webhooks) first. + +## Managing Webhooks + +`huggingface_hub` allows you to manage your webhooks programmatically. You can list your existing webhooks, create new ones, and update, enable, disable or delete them. This section guides you through the procedures using the Hugging Face Hub's API functions. + +### Creating a Webhook + +To create a new webhook, use [`create_webhook`] and specify the URL where payloads should be sent, what events should be watched, and optionally set a domain and a secret for security. + +```python +from huggingface_hub import create_webhook + +# Example: Creating a webhook +webhook = create_webhook( + url="https://webhook.site/your-custom-url", + watched=[{"type": "user", "name": "your-username"}, {"type": "org", "name": "your-org-name"}], + domains=["repo", "discussion"], + secret="your-secret" +) +``` + +### Listing Webhooks + +To see all the webhooks you have configured, you can list them with [`list_webhooks`]. This is useful to review their IDs, URLs, and statuses. + +```python +from huggingface_hub import list_webhooks + +# Example: Listing all webhooks +webhooks = list_webhooks() +for webhook in webhooks: + print(webhook) +``` + +### Updating a Webhook + +If you need to change the configuration of an existing webhook, such as the URL or the events it watches, you can update it using [`update_webhook`]. + +```python +from huggingface_hub import update_webhook + +# Example: Updating a webhook +updated_webhook = update_webhook( + webhook_id="your-webhook-id", + url="https://new.webhook.site/url", + watched=[{"type": "user", "name": "new-username"}], + domains=["repo"] +) +``` + +### Enabling and Disabling Webhooks + +You might want to temporarily disable a webhook without deleting it. This can be done using [`disable_webhook`], and the webhook can be re-enabled later with [`enable_webhook`]. + +```python +from huggingface_hub import enable_webhook, disable_webhook + +# Example: Enabling a webhook +enabled_webhook = enable_webhook("your-webhook-id") +print("Enabled:", enabled_webhook) + +# Example: Disabling a webhook +disabled_webhook = disable_webhook("your-webhook-id") +print("Disabled:", disabled_webhook) +``` + +### Deleting a Webhook + +When a webhook is no longer needed, it can be permanently deleted using [`delete_webhook`]. + +```python +from huggingface_hub import delete_webhook + +# Example: Deleting a webhook +delete_webhook("your-webhook-id") +``` + +## Webhooks Server + +The base class that we will use in this guides section is [`WebhooksServer`]. It is a class for easily configuring a server that can receive webhooks from the Huggingface Hub. The server is based on a [Gradio](https://gradio.app/) app. It has a UI to display instructions for you or your users and an API to listen to webhooks. @@ -30,7 +106,7 @@ notice. Make sure to pin the version of `huggingface_hub` in your requirements. -## Create an endpoint +### Create an endpoint Implementing a webhook endpoint is as simple as decorating a function. Let's see a first example to explain the main concepts: @@ -87,7 +163,7 @@ once even if you have multiple endpoints. -## Configure a Webhook +### Configure a Webhook Now that you have a webhook server running, you want to configure a Webhook to start receiving messages. Go to https://huggingface.co/settings/webhooks, click on "Add a new webhook" and configure your Webhook. Set the target @@ -102,7 +178,7 @@ Activity tab of your Webhook to see the events that have been triggered. Now tha test it and quickly iterate. If you modify your code and restart the server, your public URL might change. Make sure to update the webhook configuration on the Hub if needed. -## Deploy to a Space +### Deploy to a Space Now that you have a working webhook server, the goal is to deploy it to a Space. Go to https://huggingface.co/new-space to create a Space. Give it a name, select the Gradio SDK and click on "Create Space". Upload your code to the Space @@ -118,12 +194,12 @@ And this is it! Your Space is now ready to receive webhooks from the Hub. Please on a free 'cpu-basic' hardware, it will be shut down after 48 hours of inactivity. If you need a permanent Space, you should consider setting to an [upgraded hardware](https://huggingface.co/docs/hub/spaces-gpus#hardware-specs). -## Advanced usage +### Advanced usage The guide above explained the quickest way to setup a [`WebhooksServer`]. In this section, we will see how to customize it further. -### Multiple endpoints +#### Multiple endpoints You can register multiple endpoints on the same server. For example, you might want to have one endpoint to trigger a training job and another one to trigger a model evaluation. You can do this by adding multiple `@webhook_endpoint` @@ -155,7 +231,7 @@ Webhooks are correctly setup and ready to use: - POST https://1fadb0f52d8bf825fc.gradio.live/webhooks/trigger_evaluation ``` -### Custom server +#### Custom server To get more flexibility, you can also create a [`WebhooksServer`] object directly. This is useful if you want to customize the landing page of your server. You can do this by passing a [Gradio UI](https://gradio.app/docs/#blocks) diff --git a/docs/source/en/package_reference/hf_api.md b/docs/source/en/package_reference/hf_api.md index d0bead1cf8..638bb71549 100644 --- a/docs/source/en/package_reference/hf_api.md +++ b/docs/source/en/package_reference/hf_api.md @@ -99,6 +99,14 @@ models = hf_api.list_models() [[autodoc]] huggingface_hub.hf_api.UserLikes +### WebhookInfo + +[[autodoc]] huggingface_hub.hf_api.WebhookInfo + +### WebhookWatchedItem + +[[autodoc]] huggingface_hub.hf_api.WebhookWatchedItem + ## CommitOperation Below are the supported values for [`CommitOperation`]: diff --git a/src/huggingface_hub/__init__.py b/src/huggingface_hub/__init__.py index a6508ae3cc..a7f22c7824 100644 --- a/src/huggingface_hub/__init__.py +++ b/src/huggingface_hub/__init__.py @@ -150,6 +150,8 @@ "RepoUrl", "User", "UserLikes", + "WebhookInfo", + "WebhookWatchedItem", "accept_access_request", "add_collection_item", "add_space_secret", @@ -166,6 +168,7 @@ "create_pull_request", "create_repo", "create_tag", + "create_webhook", "dataset_info", "delete_branch", "delete_collection", @@ -178,8 +181,11 @@ "delete_space_storage", "delete_space_variable", "delete_tag", + "delete_webhook", + "disable_webhook", "duplicate_space", "edit_discussion_comment", + "enable_webhook", "file_exists", "get_collection", "get_dataset_tags", @@ -193,6 +199,7 @@ "get_space_runtime", "get_space_variables", "get_token_permission", + "get_webhook", "grant_access", "like", "list_accepted_access_requests", @@ -210,6 +217,7 @@ "list_repo_refs", "list_repo_tree", "list_spaces", + "list_webhooks", "merge_pull_request", "model_info", "move_repo", @@ -237,6 +245,7 @@ "update_collection_metadata", "update_inference_endpoint", "update_repo_visibility", + "update_webhook", "upload_file", "upload_folder", "whoami", @@ -630,6 +639,8 @@ def __dir__(): RepoUrl, # noqa: F401 User, # noqa: F401 UserLikes, # noqa: F401 + WebhookInfo, # noqa: F401 + WebhookWatchedItem, # noqa: F401 accept_access_request, # noqa: F401 add_collection_item, # noqa: F401 add_space_secret, # noqa: F401 @@ -646,6 +657,7 @@ def __dir__(): create_pull_request, # noqa: F401 create_repo, # noqa: F401 create_tag, # noqa: F401 + create_webhook, # noqa: F401 dataset_info, # noqa: F401 delete_branch, # noqa: F401 delete_collection, # noqa: F401 @@ -658,8 +670,11 @@ def __dir__(): delete_space_storage, # noqa: F401 delete_space_variable, # noqa: F401 delete_tag, # noqa: F401 + delete_webhook, # noqa: F401 + disable_webhook, # noqa: F401 duplicate_space, # noqa: F401 edit_discussion_comment, # noqa: F401 + enable_webhook, # noqa: F401 file_exists, # noqa: F401 get_collection, # noqa: F401 get_dataset_tags, # noqa: F401 @@ -673,6 +688,7 @@ def __dir__(): get_space_runtime, # noqa: F401 get_space_variables, # noqa: F401 get_token_permission, # noqa: F401 + get_webhook, # noqa: F401 grant_access, # noqa: F401 like, # noqa: F401 list_accepted_access_requests, # noqa: F401 @@ -690,6 +706,7 @@ def __dir__(): list_repo_refs, # noqa: F401 list_repo_tree, # noqa: F401 list_spaces, # noqa: F401 + list_webhooks, # noqa: F401 merge_pull_request, # noqa: F401 model_info, # noqa: F401 move_repo, # noqa: F401 @@ -717,6 +734,7 @@ def __dir__(): update_collection_metadata, # noqa: F401 update_inference_endpoint, # noqa: F401 update_repo_visibility, # noqa: F401 + update_webhook, # noqa: F401 upload_file, # noqa: F401 upload_folder, # noqa: F401 whoami, # noqa: F401 diff --git a/src/huggingface_hub/constants.py b/src/huggingface_hub/constants.py index 064aa60bda..fc6d8c5e44 100644 --- a/src/huggingface_hub/constants.py +++ b/src/huggingface_hub/constants.py @@ -93,6 +93,9 @@ def _as_int(value: Optional[str]) -> Optional[int]: DiscussionStatusFilter = Literal["all", "open", "closed"] DISCUSSION_STATUS: Tuple[DiscussionTypeFilter, ...] = typing.get_args(DiscussionStatusFilter) +# Webhook subscription types +WEBHOOK_DOMAIN_T = Literal["repo", "discussions"] + # default cache default_home = os.path.join(os.path.expanduser("~"), ".cache") HF_HOME = os.path.expanduser( diff --git a/src/huggingface_hub/hf_api.py b/src/huggingface_hub/hf_api.py index a7ad1004bb..d05aa2ad15 100644 --- a/src/huggingface_hub/hf_api.py +++ b/src/huggingface_hub/hf_api.py @@ -98,17 +98,18 @@ SAFETENSORS_MAX_HEADER_LENGTH, SAFETENSORS_SINGLE_FILE, SPACES_SDK_TYPES, + WEBHOOK_DOMAIN_T, DiscussionStatusFilter, DiscussionTypeFilter, ) from .file_download import HfFileMetadata, get_hf_file_metadata, hf_hub_url from .repocard_data import DatasetCardData, ModelCardData, SpaceCardData -from .utils import ( # noqa: F401 # imported for backward compatibility +from .utils import ( DEFAULT_IGNORE_PATTERNS, BadRequestError, EntryNotFoundError, GatedRepoError, - HfFolder, + HfFolder, # noqa: F401 # kept for backward compatibility HfHubHTTPError, LocalTokenNotFoundError, NotASafetensorsRepoError, @@ -389,6 +390,48 @@ class AccessRequest: fields: Optional[Dict[str, Any]] = None +@dataclass +class WebhookWatchedItem: + """Data structure containing information about the items watched by a webhook. + + Attributes: + type (`Literal["dataset", "model", "org", "space", "user"]`): + Type of the item to be watched. Can be one of `["dataset", "model", "org", "space", "user"]`. + name (`str`): + Name of the item to be watched. Can be the username, organization name, model name, dataset name or space name. + """ + + type: Literal["dataset", "model", "org", "space", "user"] + name: str + + +@dataclass +class WebhookInfo: + """Data structure containing information about a webhook. + + Attributes: + id (`str`): + ID of the webhook. + url (`str`): + URL of the webhook. + watched (`List[WebhookWatchedItem]`): + List of items watched by the webhook, see [`WebhookWatchedItem`]. + domains (`List[WEBHOOK_DOMAIN_T]`): + List of domains the webhook is watching. Can be one of `["repo", "discussions"]`. + secret (`str`, *optional*): + Secret of the webhook. + disabled (`bool`): + Whether the webhook is disabled or not. + """ + + id: str + url: str + watched: List[WebhookWatchedItem] + domains: List[WEBHOOK_DOMAIN_T] + secret: Optional[str] + disabled: bool + + class RepoUrl(str): """Subclass of `str` describing a repo URL on the Hub. @@ -8432,6 +8475,392 @@ def grant_access( hf_raise_for_status(response) return response.json() + ################### + # Manage webhooks # + ################### + + @validate_hf_hub_args + def get_webhook(self, webhook_id: str, *, token: Union[bool, str, None] = None) -> WebhookInfo: + """Get a webhook by its id. + + Args: + webhook_id (`str`): + The unique identifier of the webhook to get. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + [`WebhookInfo`]: + Info about the webhook. + + Example: + ```python + >>> from huggingface_hub import get_webhook + >>> webhook = get_webhook("654bbbc16f2ec14d77f109cc") + >>> print(webhook) + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + secret="my-secret", + domains=["repo", "discussion"], + disabled=False, + ) + ``` + """ + response = get_session().get( + f"{ENDPOINT}/api/settings/webhooks/{webhook_id}", + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhook_data = response.json()["webhook"] + + watched_items = [WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook_data["watched"]] + + webhook = WebhookInfo( + id=webhook_data["id"], + url=webhook_data["url"], + watched=watched_items, + domains=webhook_data["domains"], + secret=webhook_data.get("secret"), + disabled=webhook_data["disabled"], + ) + + return webhook + + @validate_hf_hub_args + def list_webhooks(self, *, token: Union[bool, str, None] = None) -> List[WebhookInfo]: + """List all configured webhooks. + + Args: + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + `List[WebhookInfo]`: + List of webhook info objects. + + Example: + ```python + >>> from huggingface_hub import list_webhooks + >>> webhooks = list_webhooks() + >>> len(webhooks) + 2 + >>> webhooks[0] + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + secret="my-secret", + domains=["repo", "discussion"], + disabled=False, + ) + ``` + """ + response = get_session().get( + f"{ENDPOINT}/api/settings/webhooks", + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhooks_data = response.json() + + return [ + WebhookInfo( + id=webhook["id"], + url=webhook["url"], + watched=[WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook["watched"]], + domains=webhook["domains"], + secret=webhook.get("secret"), + disabled=webhook["disabled"], + ) + for webhook in webhooks_data + ] + + @validate_hf_hub_args + def create_webhook( + self, + *, + url: str, + watched: List[Union[Dict, WebhookWatchedItem]], + domains: Optional[List[WEBHOOK_DOMAIN_T]] = None, + secret: Optional[str] = None, + token: Union[bool, str, None] = None, + ) -> WebhookInfo: + """Create a new webhook. + + Args: + url (`str`): + URL to send the payload to. + watched (`List[WebhookWatchedItem]`): + List of [`WebhookWatchedItem`] to be watched by the webhook. It can be users, orgs, models, datasets or spaces. + Watched items can also be provided as plain dictionaries. + domains (`List[Literal["repo", "discussion"]]`, optional): + List of domains to watch. It can be "repo", "discussion" or both. + secret (`str`, optional): + A secret to sign the payload with. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + [`WebhookInfo`]: + Info about the newly created webhook. + + Example: + ```python + >>> from huggingface_hub import create_webhook + >>> payload = create_webhook( + ... watched=[{"type": "user", "name": "julien-c"}, {"type": "org", "name": "HuggingFaceH4"}], + ... url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + ... domains=["repo", "discussion"], + ... secret="my-secret", + ... ) + >>> print(payload) + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + domains=["repo", "discussion"], + secret="my-secret", + disabled=False, + ) + ``` + """ + watched_dicts = [asdict(item) if isinstance(item, WebhookWatchedItem) else item for item in watched] + + response = get_session().post( + f"{ENDPOINT}/api/settings/webhooks", + json={"watched": watched_dicts, "url": url, "domains": domains, "secret": secret}, + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhook_data = response.json()["webhook"] + watched_items = [WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook_data["watched"]] + + webhook = WebhookInfo( + id=webhook_data["id"], + url=webhook_data["url"], + watched=watched_items, + domains=webhook_data["domains"], + secret=webhook_data.get("secret"), + disabled=webhook_data["disabled"], + ) + + return webhook + + @validate_hf_hub_args + def update_webhook( + self, + webhook_id: str, + *, + url: Optional[str] = None, + watched: Optional[List[Union[Dict, WebhookWatchedItem]]] = None, + domains: Optional[List[WEBHOOK_DOMAIN_T]] = None, + secret: Optional[str] = None, + token: Union[bool, str, None] = None, + ) -> WebhookInfo: + """Update an existing webhook. + + Args: + webhook_id (`str`): + The unique identifier of the webhook to be updated. + url (`str`, optional): + The URL to which the payload will be sent. + watched (`List[WebhookWatchedItem]`, optional): + List of items to watch. It can be users, orgs, models, datasets, or spaces. + Refer to [`WebhookWatchedItem`] for more details. Watched items can also be provided as plain dictionaries. + domains (`List[Literal["repo", "discussion"]]`, optional): + The domains to watch. This can include "repo", "discussion", or both. + secret (`str`, optional): + A secret to sign the payload with, providing an additional layer of security. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + [`WebhookInfo`]: + Info about the updated webhook. + + Example: + ```python + >>> from huggingface_hub import update_webhook + >>> updated_payload = update_webhook( + ... webhook_id="654bbbc16f2ec14d77f109cc", + ... url="https://new.webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + ... watched=[{"type": "user", "name": "julien-c"}, {"type": "org", "name": "HuggingFaceH4"}], + ... domains=["repo"], + ... secret="my-secret", + ... ) + >>> print(updated_payload) + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + url="https://new.webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + domains=["repo"], + secret="my-secret", + disabled=False, + ``` + """ + if watched is None: + watched = [] + watched_dicts = [asdict(item) if isinstance(item, WebhookWatchedItem) else item for item in watched] + + response = get_session().post( + f"{ENDPOINT}/api/settings/webhooks/{webhook_id}", + json={"watched": watched_dicts, "url": url, "domains": domains, "secret": secret}, + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhook_data = response.json()["webhook"] + + watched_items = [WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook_data["watched"]] + + webhook = WebhookInfo( + id=webhook_data["id"], + url=webhook_data["url"], + watched=watched_items, + domains=webhook_data["domains"], + secret=webhook_data.get("secret"), + disabled=webhook_data["disabled"], + ) + + return webhook + + @validate_hf_hub_args + def enable_webhook(self, webhook_id: str, *, token: Union[bool, str, None] = None) -> WebhookInfo: + """Enable a webhook (makes it "active"). + + Args: + webhook_id (`str`): + The unique identifier of the webhook to enable. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + [`WebhookInfo`]: + Info about the enabled webhook. + + Example: + ```python + >>> from huggingface_hub import enable_webhook + >>> enabled_webhook = enable_webhook("654bbbc16f2ec14d77f109cc") + >>> enabled_webhook + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + domains=["repo", "discussion"], + secret="my-secret", + disabled=False, + ) + ``` + """ + response = get_session().post( + f"{ENDPOINT}/api/settings/webhooks/{webhook_id}/enable", + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhook_data = response.json()["webhook"] + + watched_items = [WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook_data["watched"]] + + webhook = WebhookInfo( + id=webhook_data["id"], + url=webhook_data["url"], + watched=watched_items, + domains=webhook_data["domains"], + secret=webhook_data.get("secret"), + disabled=webhook_data["disabled"], + ) + + return webhook + + @validate_hf_hub_args + def disable_webhook(self, webhook_id: str, *, token: Union[bool, str, None] = None) -> WebhookInfo: + """Disable a webhook (makes it "disabled"). + + Args: + webhook_id (`str`): + The unique identifier of the webhook to disable. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + [`WebhookInfo`]: + Info about the disabled webhook. + + Example: + ```python + >>> from huggingface_hub import disable_webhook + >>> disabled_webhook = disable_webhook("654bbbc16f2ec14d77f109cc") + >>> disabled_webhook + WebhookInfo( + id="654bbbc16f2ec14d77f109cc", + url="https://webhook.site/a2176e82-5720-43ee-9e06-f91cb4c91548", + watched=[WebhookWatchedItem(type="user", name="julien-c"), WebhookWatchedItem(type="org", name="HuggingFaceH4")], + domains=["repo", "discussion"], + secret="my-secret", + disabled=True, + ) + ``` + """ + response = get_session().post( + f"{ENDPOINT}/api/settings/webhooks/{webhook_id}/disable", + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + webhook_data = response.json()["webhook"] + + watched_items = [WebhookWatchedItem(type=item["type"], name=item["name"]) for item in webhook_data["watched"]] + + webhook = WebhookInfo( + id=webhook_data["id"], + url=webhook_data["url"], + watched=watched_items, + domains=webhook_data["domains"], + secret=webhook_data.get("secret"), + disabled=webhook_data["disabled"], + ) + + return webhook + + @validate_hf_hub_args + def delete_webhook(self, webhook_id: str, *, token: Union[bool, str, None] = None) -> None: + """Delete a webhook. + + Args: + webhook_id (`str`): + The unique identifier of the webhook to delete. + token (Union[bool, str, None], optional): + A valid user access token (string). Defaults to the locally saved token, which is the recommended + method for authentication (see https://huggingface.co/docs/huggingface_hub/quick-start#authentication). + To disable authentication, pass `False`. + + Returns: + `None` + + Example: + ```python + >>> from huggingface_hub import delete_webhook + >>> delete_webhook("654bbbc16f2ec14d77f109cc") + ``` + """ + response = get_session().delete( + f"{ENDPOINT}/api/settings/webhooks/{webhook_id}", + headers=self._build_hf_headers(token=token), + ) + hf_raise_for_status(response) + ############# # Internals # ############# @@ -8760,6 +9189,16 @@ def _parse_revision_from_pr_url(pr_url: str) -> str: reject_access_request = api.reject_access_request grant_access = api.grant_access +# Webhooks API +create_webhook = api.create_webhook +disable_webhook = api.disable_webhook +delete_webhook = api.delete_webhook +enable_webhook = api.enable_webhook +get_webhook = api.get_webhook +list_webhooks = api.list_webhooks +update_webhook = api.update_webhook + + # User API get_user_overview = api.get_user_overview list_organization_members = api.list_organization_members diff --git a/tests/test_hf_api.py b/tests/test_hf_api.py index b64a266939..d32343126b 100644 --- a/tests/test_hf_api.py +++ b/tests/test_hf_api.py @@ -62,6 +62,8 @@ RepoUrl, SpaceInfo, SpaceRuntime, + WebhookInfo, + WebhookWatchedItem, repo_type_and_id_from_hf_id, ) from huggingface_hub.repocard_data import DatasetCardData, ModelCardData @@ -3702,3 +3704,69 @@ def test_user_followers(self) -> None: def test_user_following(self) -> None: following = self.api.list_user_following("julien-c") self.assertGreater(len(list(following)), 10) + + +class WebhookApiTest(HfApiCommonTest): + def setUp(self) -> None: + super().setUp() + self.webhook_url = "https://webhook.site/test" + self.watched_items = [ + WebhookWatchedItem(type="user", name="julien-c"), # can be either a dataclass + {"type": "org", "name": "HuggingFaceH4"}, # or a simple dictionary + ] + self.domains = ["repo", "discussion"] + self.secret = "my-secret" + + # Create a webhook to be used in the tests + self.webhook = self._api.create_webhook( + url=self.webhook_url, watched=self.watched_items, domains=self.domains, secret=self.secret + ) + + def tearDown(self) -> None: + # Clean up the created webhook + self._api.delete_webhook(self.webhook.id) + super().tearDown() + + def test_get_webhook(self) -> None: + webhook = self._api.get_webhook(self.webhook.id) + self.assertIsInstance(webhook, WebhookInfo) + self.assertEqual(webhook.id, self.webhook.id) + self.assertEqual(webhook.url, self.webhook_url) + + def test_list_webhooks(self) -> None: + webhooks = self._api.list_webhooks() + self.assertTrue(any(webhook.id == self.webhook.id for webhook in webhooks)) + + def test_create_webhook(self) -> None: + new_webhook = self._api.create_webhook( + url=self.webhook_url, watched=self.watched_items, domains=self.domains, secret=self.secret + ) + self.assertIsInstance(new_webhook, WebhookInfo) + self.assertEqual(new_webhook.url, self.webhook_url) + + # Clean up the newly created webhook + self._api.delete_webhook(new_webhook.id) + + def test_update_webhook(self) -> None: + updated_url = "https://webhook.site/new" + updated_webhook = self._api.update_webhook( + self.webhook.id, url=updated_url, watched=self.watched_items, domains=self.domains, secret=self.secret + ) + self.assertEqual(updated_webhook.url, updated_url) + + def test_enable_webhook(self) -> None: + enabled_webhook = self._api.enable_webhook(self.webhook.id) + self.assertFalse(enabled_webhook.disabled) + + def test_disable_webhook(self) -> None: + disabled_webhook = self._api.disable_webhook(self.webhook.id) + self.assertTrue(disabled_webhook.disabled) + + def test_delete_webhook(self) -> None: + # Create another webhook to test deletion + webhook_to_delete = self._api.create_webhook( + url=self.webhook_url, watched=self.watched_items, domains=self.domains, secret=self.secret + ) + self._api.delete_webhook(webhook_to_delete.id) + with self.assertRaises(HTTPError): + self._api.get_webhook(webhook_to_delete.id)