webhooks.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. # Copyright 2026 The HuggingFace Team. All rights reserved.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Contains commands to manage webhooks on the Hugging Face Hub.
  15. Usage:
  16. # list all webhooks
  17. hf webhooks ls
  18. # show details of a single webhook
  19. hf webhooks info <webhook_id>
  20. # create a new webhook
  21. hf webhooks create --url https://example.com/hook --watch model:bert-base-uncased
  22. # create a webhook watching multiple items and domains
  23. hf webhooks create --url https://example.com/hook --watch org:HuggingFace --watch model:gpt2 --domain repo
  24. # update a webhook
  25. hf webhooks update <webhook_id> --url https://new-url.com/hook
  26. # enable / disable a webhook
  27. hf webhooks enable <webhook_id>
  28. hf webhooks disable <webhook_id>
  29. # delete a webhook
  30. hf webhooks delete <webhook_id>
  31. """
  32. import enum
  33. from typing import Annotated, get_args, get_type_hints
  34. import typer
  35. from huggingface_hub.constants import WEBHOOK_DOMAIN_T
  36. from huggingface_hub.hf_api import WebhookWatchedItem
  37. from ._cli_utils import (
  38. FormatWithAutoOpt,
  39. TokenOpt,
  40. get_hf_api,
  41. typer_factory,
  42. )
  43. from ._output import OutputFormatWithAuto, out
  44. # Build enums dynamically from Literal types to avoid duplication
  45. _WATCHED_TYPES = get_args(get_type_hints(WebhookWatchedItem)["type"])
  46. WatchedItemType = enum.Enum("WatchedItemType", {t: t for t in _WATCHED_TYPES}, type=str) # type: ignore[misc]
  47. _DOMAIN_TYPES = get_args(WEBHOOK_DOMAIN_T)
  48. WebhookDomain = enum.Enum("WebhookDomain", {d: d for d in _DOMAIN_TYPES}, type=str) # type: ignore[misc]
  49. def _parse_watch(values: list[str]) -> list[WebhookWatchedItem]:
  50. """Parse 'type:name' strings into WebhookWatchedItem objects.
  51. Args:
  52. values: List of strings in the format 'type:name'
  53. (e.g., 'model:bert-base-uncased', 'org:HuggingFace').
  54. Returns:
  55. List of WebhookWatchedItem objects.
  56. Raises:
  57. typer.BadParameter: If any value doesn't match the expected format.
  58. """
  59. items = []
  60. valid_types = tuple(_WATCHED_TYPES)
  61. for v in values:
  62. if ":" not in v:
  63. raise typer.BadParameter(
  64. f"Expected format 'type:name' (e.g. 'model:bert-base-uncased'), got '{v}'."
  65. f" Valid types: {', '.join(valid_types)}."
  66. )
  67. kind, name = v.split(":", 1)
  68. if kind not in valid_types:
  69. raise typer.BadParameter(f"Invalid type '{kind}'. Valid types: {', '.join(valid_types)}.")
  70. items.append(WebhookWatchedItem(type=kind, name=name)) # type: ignore
  71. return items
  72. webhooks_cli = typer_factory(help="Manage webhooks on the Hub.")
  73. @webhooks_cli.command(
  74. "list | ls",
  75. examples=[
  76. "hf webhooks ls",
  77. "hf webhooks ls --format json",
  78. "hf webhooks ls --format quiet",
  79. ],
  80. )
  81. def webhooks_ls(
  82. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  83. token: TokenOpt = None,
  84. ) -> None:
  85. """List all webhooks for the current user."""
  86. api = get_hf_api(token=token)
  87. results = [
  88. {
  89. "id": w.id,
  90. "url": w.url or "(job)",
  91. "disabled": w.disabled,
  92. "domains": w.domains or [],
  93. "watched": [f"{wi.type}:{wi.name}" for wi in (w.watched or [])],
  94. }
  95. for w in api.list_webhooks()
  96. ]
  97. out.table(results)
  98. @webhooks_cli.command(
  99. "info",
  100. examples=[
  101. "hf webhooks info abc123",
  102. ],
  103. )
  104. def webhooks_info(
  105. webhook_id: Annotated[str, typer.Argument(help="The ID of the webhook.")],
  106. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  107. token: TokenOpt = None,
  108. ) -> None:
  109. """Show full details for a single webhook."""
  110. api = get_hf_api(token=token)
  111. webhook = api.get_webhook(webhook_id)
  112. out.dict(webhook)
  113. @webhooks_cli.command(
  114. "create",
  115. examples=[
  116. "hf webhooks create --url https://example.com/hook --watch model:bert-base-uncased",
  117. "hf webhooks create --url https://example.com/hook --watch org:HuggingFace --watch model:gpt2 --domain repo",
  118. "hf webhooks create --job-id 687f911eaea852de79c4a50a --watch user:julien-c",
  119. ],
  120. )
  121. def webhooks_create(
  122. watch: Annotated[
  123. list[str],
  124. typer.Option(
  125. "--watch",
  126. help="Item to watch, in 'type:name' format (e.g. 'model:bert-base-uncased'). Repeatable.",
  127. ),
  128. ],
  129. url: Annotated[
  130. str | None,
  131. typer.Option(help="URL to send webhook payloads to. Mutually exclusive with --job-id."),
  132. ] = None,
  133. job_id: Annotated[
  134. str | None,
  135. typer.Option(
  136. "--job-id",
  137. help="ID of a Job to trigger (from job.id) instead of pinging a URL. Mutually exclusive with --url.",
  138. ),
  139. ] = None,
  140. domain: Annotated[
  141. list[WebhookDomain] | None,
  142. typer.Option(
  143. "--domain",
  144. help="Domain to watch: 'repo' or 'discussions'. Repeatable. Defaults to all domains.",
  145. ),
  146. ] = None,
  147. secret: Annotated[
  148. str | None,
  149. typer.Option(help="Optional secret used to sign webhook payloads."),
  150. ] = None,
  151. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  152. token: TokenOpt = None,
  153. ) -> None:
  154. """Create a new webhook.
  155. Provide either --url (to ping a remote server) or --job-id (to trigger a Job), but not both.
  156. """
  157. if url is not None and job_id is not None:
  158. raise typer.BadParameter("Provide either --url or --job-id, not both.")
  159. if url is None and job_id is None:
  160. raise typer.BadParameter("Provide either --url or --job-id.")
  161. api = get_hf_api(token=token)
  162. watched_items = _parse_watch(watch)
  163. domains = [d.value for d in domain] if domain else None
  164. webhook = api.create_webhook(url=url, job_id=job_id, watched=watched_items, domains=domains, secret=secret) # type: ignore
  165. out.result("Webhook created", id=webhook.id)
  166. @webhooks_cli.command(
  167. "update",
  168. examples=[
  169. "hf webhooks update abc123 --url https://new-url.com/hook",
  170. "hf webhooks update abc123 --watch model:gpt2 --domain repo",
  171. "hf webhooks update abc123 --secret newsecret",
  172. ],
  173. )
  174. def webhooks_update(
  175. webhook_id: Annotated[str, typer.Argument(help="The ID of the webhook to update.")],
  176. url: Annotated[
  177. str | None,
  178. typer.Option(help="New URL to send webhook payloads to."),
  179. ] = None,
  180. watch: Annotated[
  181. list[str] | None,
  182. typer.Option(
  183. "--watch",
  184. help=(
  185. "New list of items to watch, in 'type:name' format. "
  186. "Repeatable. Replaces the entire existing watched list."
  187. ),
  188. ),
  189. ] = None,
  190. domain: Annotated[
  191. list[WebhookDomain] | None,
  192. typer.Option(
  193. "--domain",
  194. help="New list of domains to watch: 'repo' or 'discussions'. Repeatable.",
  195. ),
  196. ] = None,
  197. secret: Annotated[
  198. str | None,
  199. typer.Option(help="New secret used to sign webhook payloads."),
  200. ] = None,
  201. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  202. token: TokenOpt = None,
  203. ) -> None:
  204. """Update an existing webhook. Only provided options are changed."""
  205. api = get_hf_api(token=token)
  206. watched_items = _parse_watch(watch) if watch else None
  207. domains = [d.value for d in domain] if domain else None
  208. webhook = api.update_webhook(webhook_id, url=url, watched=watched_items, domains=domains, secret=secret) # type: ignore
  209. out.result("Webhook updated", id=webhook.id)
  210. @webhooks_cli.command(
  211. "enable",
  212. examples=[
  213. "hf webhooks enable abc123",
  214. ],
  215. )
  216. def webhooks_enable(
  217. webhook_id: Annotated[str, typer.Argument(help="The ID of the webhook to enable.")],
  218. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  219. token: TokenOpt = None,
  220. ) -> None:
  221. """Enable a disabled webhook."""
  222. api = get_hf_api(token=token)
  223. webhook = api.enable_webhook(webhook_id)
  224. out.result("Webhook enabled", id=webhook.id)
  225. @webhooks_cli.command(
  226. "disable",
  227. examples=[
  228. "hf webhooks disable abc123",
  229. ],
  230. )
  231. def webhooks_disable(
  232. webhook_id: Annotated[str, typer.Argument(help="The ID of the webhook to disable.")],
  233. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  234. token: TokenOpt = None,
  235. ) -> None:
  236. """Disable an active webhook."""
  237. api = get_hf_api(token=token)
  238. webhook = api.disable_webhook(webhook_id)
  239. out.result("Webhook disabled", id=webhook.id)
  240. @webhooks_cli.command(
  241. "delete",
  242. examples=[
  243. "hf webhooks delete abc123",
  244. "hf webhooks delete abc123 --yes",
  245. ],
  246. )
  247. def webhooks_delete(
  248. webhook_id: Annotated[str, typer.Argument(help="The ID of the webhook to delete.")],
  249. yes: Annotated[
  250. bool,
  251. typer.Option(
  252. "--yes",
  253. "-y",
  254. help="Skip confirmation prompt.",
  255. ),
  256. ] = False,
  257. format: FormatWithAutoOpt = OutputFormatWithAuto.auto,
  258. token: TokenOpt = None,
  259. ) -> None:
  260. """Delete a webhook permanently."""
  261. out.confirm(f"Are you sure you want to delete webhook '{webhook_id}'?", yes=yes)
  262. api = get_hf_api(token=token)
  263. api.delete_webhook(webhook_id)
  264. out.result("Webhook deleted", id=webhook_id)