Wauplin HF staff commited on
Commit
d8053fc
1 Parent(s): 26dfc12

New UI to register/unregister spaces on demand

Browse files
Files changed (4) hide show
  1. app.py +39 -43
  2. database.py +75 -0
  3. gradio_webhooks.py +4 -16
  4. ui.py +84 -0
app.py CHANGED
@@ -2,7 +2,7 @@ import os
2
  from pathlib import Path
3
  from typing import Literal
4
 
5
- from fastapi import BackgroundTasks, HTTPException
6
  from huggingface_hub import (
7
  CommitOperationAdd,
8
  CommitOperationDelete,
@@ -16,42 +16,47 @@ from huggingface_hub import (
16
  )
17
  from huggingface_hub.repocard import RepoCard
18
  from requests import HTTPError
19
-
20
  from gradio_webhooks import GradioWebhookApp, WebhookPayload
 
 
 
21
 
22
- HF_TOKEN = os.getenv("HF_TOKEN")
23
 
 
24
 
25
- app = GradioWebhookApp()
26
 
27
 
28
  @app.add_webhook("/webhook")
29
  async def post_webhook(payload: WebhookPayload, task_queue: BackgroundTasks):
30
  if payload.repo.type != "space":
31
- print("HTTP 400: not a space")
32
  raise HTTPException(400, f"Must be a Space, not {payload.repo.type}")
33
 
34
  space_id = payload.repo.name
 
 
35
 
 
36
  if (
 
37
  payload.event.scope.startswith("discussion")
38
  and payload.event.action == "create"
39
  and payload.discussion is not None
40
  and payload.discussion.isPullRequest
41
  and payload.discussion.status == "open"
42
  ):
43
- # New PR!
44
  if not is_pr_synced(space_id=space_id, pr_num=payload.discussion.num):
 
45
  task_queue.add_task(
46
  sync_ci_space,
47
  space_id=space_id,
48
  pr_num=payload.discussion.num,
49
  private=payload.repo.private,
50
  )
51
- print("New PR! Sync task scheduled")
52
- else:
53
- print("New comment on PR but CI space already synced")
54
  elif (
 
55
  payload.event.scope.startswith("discussion")
56
  and payload.event.action == "update"
57
  and payload.discussion is not None
@@ -61,47 +66,44 @@ async def post_webhook(payload: WebhookPayload, task_queue: BackgroundTasks):
61
  or payload.discussion.status == "closed"
62
  )
63
  ):
64
- # PR merged or closed!
65
  task_queue.add_task(
66
  delete_ci_space,
67
  space_id=space_id,
68
  pr_num=payload.discussion.num,
69
  )
70
- print("PR is merged (or closed)! Delete task scheduled")
71
  elif (
 
72
  payload.event.scope.startswith("repo.content")
73
  and payload.event.action == "update"
74
  ):
75
  # New repo change. Is it a commit on a PR?
76
  # => loop through all PRs and check if new changes happened
77
- print("New repo content update. Checking PRs state.")
78
- for discussion in get_repo_discussions(
79
- repo_id=space_id, repo_type="space", token=HF_TOKEN
80
- ):
81
  if discussion.is_pull_request and discussion.status == "open":
82
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
 
83
  task_queue.add_task(
84
  sync_ci_space,
85
  space_id=space_id,
86
  pr_num=discussion.num,
87
  private=payload.repo.private,
88
  )
89
- print(f"Scheduled update for PR {discussion.num}.")
90
- print(f"Done looping over PRs.")
91
- else:
92
- print(f"Webhook ignored.")
93
 
94
- print(f"Done.")
95
- return {"processed": True}
 
 
 
 
96
 
97
 
98
  def is_pr_synced(space_id: str, pr_num: int) -> bool:
99
  # What is the last synced commit for this PR?
100
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
101
  try:
102
- card = RepoCard.load(
103
- repo_id_or_path=ci_space_id, repo_type="space", token=HF_TOKEN
104
- )
105
  last_synced_sha = getattr(card.data, "synced_sha", None)
106
  except HTTPError:
107
  last_synced_sha = None
@@ -124,7 +126,6 @@ def sync_ci_space(space_id: str, pr_num: int, private: bool) -> None:
124
  repo_type="space",
125
  space_sdk="docker",
126
  private=private,
127
- token=HF_TOKEN,
128
  )
129
  is_new = True
130
  except HTTPError as err:
@@ -136,10 +137,7 @@ def sync_ci_space(space_id: str, pr_num: int, private: bool) -> None:
136
  # Download space codebase from PR revision
137
  snapshot_path = Path(
138
  snapshot_download(
139
- repo_id=space_id,
140
- revision=f"refs/pr/{pr_num}",
141
- repo_type="space",
142
- token=HF_TOKEN,
143
  )
144
  )
145
 
@@ -170,7 +168,6 @@ def sync_ci_space(space_id: str, pr_num: int, private: bool) -> None:
170
  repo_type="space",
171
  operations=operations,
172
  commit_message=f"Sync CI Space with PR {pr_num}.",
173
- token=HF_TOKEN,
174
  )
175
 
176
  # Post a comment on the PR
@@ -180,7 +177,7 @@ def sync_ci_space(space_id: str, pr_num: int, private: bool) -> None:
180
  def delete_ci_space(space_id: str, pr_num: int) -> None:
181
  # Delete
182
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
183
- delete_repo(repo_id=ci_space_id, repo_type="space", token=HF_TOKEN)
184
 
185
  # Notify about deletion
186
  notify_pr(space_id=space_id, pr_num=pr_num, action="delete")
@@ -200,38 +197,37 @@ def notify_pr(
200
  raise ValueError(f"Status {action} not handled.")
201
 
202
  comment_discussion(
203
- repo_id=space_id,
204
- repo_type="space",
205
- discussion_num=pr_num,
206
- comment=comment,
207
- token=HF_TOKEN,
208
  )
209
 
210
 
211
  def _get_ci_space_id(space_id: str, pr_num: int) -> str:
212
- return f"{space_id}-ci-pr-{pr_num}"
213
 
214
 
215
  NOTIFICATION_TEMPLATE_CREATE = """\
216
  Hey there!
217
- Following the creation of this PR, a temporary test Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been launched.
218
  Any changes pushed to this PR will be synced with the test Space.
219
 
220
- (This is an automated message)
 
 
 
221
  """
222
 
223
  NOTIFICATION_TEMPLATE_UPDATE = """\
224
  Hey there!
225
- Following new commits that happened in this PR, the temporary test Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been updated.
226
 
227
- (This is an automated message)
228
  """
229
 
230
  NOTIFICATION_TEMPLATE_DELETE = """\
231
  Hey there!
232
- PR is now merged/closed. The temporary test Space has been deleted.
233
 
234
- (This is an automated message)
235
  """
236
 
237
 
 
2
  from pathlib import Path
3
  from typing import Literal
4
 
5
+ from fastapi import BackgroundTasks, HTTPException, Response, status
6
  from huggingface_hub import (
7
  CommitOperationAdd,
8
  CommitOperationDelete,
 
16
  )
17
  from huggingface_hub.repocard import RepoCard
18
  from requests import HTTPError
 
19
  from gradio_webhooks import GradioWebhookApp, WebhookPayload
20
+ from huggingface_hub import login
21
+ from ui import generate_ui
22
+ from database import is_space_registered
23
 
24
+ login(token=os.getenv("HF_TOKEN"))
25
 
26
+ CI_BOT_NAME = "spaces-ci-bot"
27
 
28
+ app = GradioWebhookApp(ui=generate_ui())
29
 
30
 
31
  @app.add_webhook("/webhook")
32
  async def post_webhook(payload: WebhookPayload, task_queue: BackgroundTasks):
33
  if payload.repo.type != "space":
 
34
  raise HTTPException(400, f"Must be a Space, not {payload.repo.type}")
35
 
36
  space_id = payload.repo.name
37
+ if not is_space_registered(space_id):
38
+ return "Space not in the watchlist."
39
 
40
+ has_task = False
41
  if (
42
+ # Means "a new PR has been opened"
43
  payload.event.scope.startswith("discussion")
44
  and payload.event.action == "create"
45
  and payload.discussion is not None
46
  and payload.discussion.isPullRequest
47
  and payload.discussion.status == "open"
48
  ):
 
49
  if not is_pr_synced(space_id=space_id, pr_num=payload.discussion.num):
50
+ # New PR! Sync task scheduled
51
  task_queue.add_task(
52
  sync_ci_space,
53
  space_id=space_id,
54
  pr_num=payload.discussion.num,
55
  private=payload.repo.private,
56
  )
57
+ has_task = True
 
 
58
  elif (
59
+ # Means "a PR has been merged or closed"
60
  payload.event.scope.startswith("discussion")
61
  and payload.event.action == "update"
62
  and payload.discussion is not None
 
66
  or payload.discussion.status == "closed"
67
  )
68
  ):
 
69
  task_queue.add_task(
70
  delete_ci_space,
71
  space_id=space_id,
72
  pr_num=payload.discussion.num,
73
  )
74
+ has_task = True
75
  elif (
76
+ # Means "some content has been pushed to the Space" (any branch)
77
  payload.event.scope.startswith("repo.content")
78
  and payload.event.action == "update"
79
  ):
80
  # New repo change. Is it a commit on a PR?
81
  # => loop through all PRs and check if new changes happened
82
+ for discussion in get_repo_discussions(repo_id=space_id, repo_type="space"):
 
 
 
83
  if discussion.is_pull_request and discussion.status == "open":
84
  if not is_pr_synced(space_id=space_id, pr_num=discussion.num):
85
+ # Found a PR that is not yet synced
86
  task_queue.add_task(
87
  sync_ci_space,
88
  space_id=space_id,
89
  pr_num=discussion.num,
90
  private=payload.repo.private,
91
  )
92
+ has_task = True
 
 
 
93
 
94
+ if has_task:
95
+ return Response(
96
+ "Task scheduled to sync/delete Space", status_code=status.HTTP_202_ACCEPTED
97
+ )
98
+ else:
99
+ return Response("No task scheduled", status_code=status.HTTP_202_ACCEPTED)
100
 
101
 
102
  def is_pr_synced(space_id: str, pr_num: int) -> bool:
103
  # What is the last synced commit for this PR?
104
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
105
  try:
106
+ card = RepoCard.load(repo_id_or_path=ci_space_id, repo_type="space")
 
 
107
  last_synced_sha = getattr(card.data, "synced_sha", None)
108
  except HTTPError:
109
  last_synced_sha = None
 
126
  repo_type="space",
127
  space_sdk="docker",
128
  private=private,
 
129
  )
130
  is_new = True
131
  except HTTPError as err:
 
137
  # Download space codebase from PR revision
138
  snapshot_path = Path(
139
  snapshot_download(
140
+ repo_id=space_id, revision=f"refs/pr/{pr_num}", repo_type="space"
 
 
 
141
  )
142
  )
143
 
 
168
  repo_type="space",
169
  operations=operations,
170
  commit_message=f"Sync CI Space with PR {pr_num}.",
 
171
  )
172
 
173
  # Post a comment on the PR
 
177
  def delete_ci_space(space_id: str, pr_num: int) -> None:
178
  # Delete
179
  ci_space_id = _get_ci_space_id(space_id=space_id, pr_num=pr_num)
180
+ delete_repo(repo_id=ci_space_id, repo_type="space")
181
 
182
  # Notify about deletion
183
  notify_pr(space_id=space_id, pr_num=pr_num, action="delete")
 
197
  raise ValueError(f"Status {action} not handled.")
198
 
199
  comment_discussion(
200
+ repo_id=space_id, repo_type="space", discussion_num=pr_num, comment=comment
 
 
 
 
201
  )
202
 
203
 
204
  def _get_ci_space_id(space_id: str, pr_num: int) -> str:
205
+ return f"{CI_BOT_NAME}-{space_id.replace('/', '-')}-ci-pr-{pr_num}"
206
 
207
 
208
  NOTIFICATION_TEMPLATE_CREATE = """\
209
  Hey there!
210
+ Following the creation of this PR, an ephemeral Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been launched.
211
  Any changes pushed to this PR will be synced with the test Space.
212
 
213
+ If your Space needs configuration (secrets or upgraded hardware), you must duplicate this ephemeral Space to your account and configure the settings by yourself.
214
+ You are responsible of making sure that the changes introduced in the PR are not harmful (leak secret, run malicious scripts,...)
215
+
216
+ (This is an automated message. To disable the Spaces CI Bot, please unregister using [this form](https://huggingface.co/spaces/spaces-ci-bot/webhook))
217
  """
218
 
219
  NOTIFICATION_TEMPLATE_UPDATE = """\
220
  Hey there!
221
+ Following new commits that happened in this PR, the ephemeral Space [{ci_space_id}](https://huggingface.co/spaces/{ci_space_id}) has been updated.
222
 
223
+ (This is an automated message. To disable the Spaces CI Bot, please unregister using [this form](https://huggingface.co/spaces/spaces-ci-bot/webhook))
224
  """
225
 
226
  NOTIFICATION_TEMPLATE_DELETE = """\
227
  Hey there!
228
+ PR is now merged/closed. The ephemeral Space has been deleted.
229
 
230
+ (This is an automated message. To disable the Spaces CI Bot, please unregister using [this form](https://huggingface.co/spaces/spaces-ci-bot/webhook))
231
  """
232
 
233
 
database.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from huggingface_hub import hf_hub_download, upload_file, space_info
2
+ from huggingface_hub.utils import HfHubHTTPError
3
+ from pathlib import Path
4
+ from typing import Set
5
+
6
+ DATABASE_REPO_ID = "spaces-ci-bot/webhook"
7
+ DATABASE_FILE = "registered_spaces.txt"
8
+
9
+ MAX_NB_ATTEMPTS = 10
10
+
11
+
12
+ def is_space_existing(space_id: str) -> bool:
13
+ try:
14
+ space_info(repo_id=space_id)
15
+ return True
16
+ except HfHubHTTPError:
17
+ return False
18
+
19
+
20
+ def is_space_registered(space_id: str) -> bool:
21
+ return space_id in get_registered_spaces()
22
+
23
+
24
+ def get_registered_spaces() -> Set[str]:
25
+ return _read_database(_get_latest_file())
26
+
27
+
28
+ def update_status(space_id: str, should_watch: bool) -> None:
29
+ nb_attempts = 0
30
+ while True:
31
+ # Get registered spaces
32
+ filepath = _get_latest_file()
33
+ registered_spaces = _read_database(filepath)
34
+
35
+ # Do nothing if:
36
+ # - need to register and already registered
37
+ # - need to unregister and already not registered
38
+ if (should_watch and space_id in registered_spaces) or (
39
+ not should_watch and space_id not in registered_spaces
40
+ ):
41
+ return
42
+
43
+ # Else, (un)register new space
44
+ latest_revision = filepath.parent.name
45
+ if should_watch:
46
+ registered_spaces.add(space_id)
47
+ else:
48
+ registered_spaces.remove(space_id)
49
+
50
+ # Re-upload database and ensure no concurrent call happened
51
+ try:
52
+ nb_attempts += 1
53
+ upload_file(
54
+ path_in_repo=DATABASE_FILE,
55
+ repo_id=DATABASE_REPO_ID,
56
+ repo_type="dataset",
57
+ path_or_fileobj="\n".join(sorted(registered_spaces)).encode(),
58
+ commit_message=(
59
+ f"Register {space_id}" if should_watch else f"Unregister {space_id}"
60
+ ),
61
+ parent_commit=latest_revision, # ensure consistency
62
+ )
63
+ return
64
+ except HfHubHTTPError:
65
+ # Retry X times before giving up (in case multiple registrations at the same time)
66
+ if nb_attempts == MAX_NB_ATTEMPTS:
67
+ raise
68
+
69
+
70
+ def _read_database(filepath: Path) -> Set[str]:
71
+ return set(filepath.read_text().split())
72
+
73
+
74
+ def _get_latest_file() -> Path:
75
+ return Path(hf_hub_download(DATABASE_REPO_ID, DATABASE_FILE, repo_type="dataset"))
gradio_webhooks.py CHANGED
@@ -1,6 +1,5 @@
1
  import os
2
- from pathlib import Path
3
- from typing import Literal, Optional, Set, Union
4
 
5
  import gradio as gr
6
  from fastapi import Request
@@ -27,25 +26,14 @@ class GradioWebhookApp:
27
 
28
  def __init__(
29
  self,
30
- landing_path: Union[str, Path] = "README.md",
31
  webhook_secret: Optional[str] = None,
32
  ) -> None:
33
- # Use README.md as landing page or provide any markdown file
34
- landing_path = Path(landing_path)
35
- landing_content = landing_path.read_text()
36
- if landing_path.name == "README.md":
37
- landing_content = landing_content.split("---")[-1].strip()
38
-
39
- # Simple gradio app with landing content
40
- block = gr.Blocks()
41
- with block:
42
- gr.Markdown(landing_content)
43
-
44
  # Launch gradio app:
45
  # - as non-blocking so that webhooks can be added afterwards
46
  # - as shared if launch locally (to receive webhooks)
47
- app, _, _ = block.launch(prevent_thread_lock=True, share=not block.is_space)
48
- self.gradio_app = block
49
  self.fastapi_app = app
50
  self.webhook_paths: Set[str] = set()
51
 
 
1
  import os
2
+ from typing import Literal, Optional, Set
 
3
 
4
  import gradio as gr
5
  from fastapi import Request
 
26
 
27
  def __init__(
28
  self,
29
+ ui: gr.Blocks,
30
  webhook_secret: Optional[str] = None,
31
  ) -> None:
 
 
 
 
 
 
 
 
 
 
 
32
  # Launch gradio app:
33
  # - as non-blocking so that webhooks can be added afterwards
34
  # - as shared if launch locally (to receive webhooks)
35
+ app, _, _ = ui.launch(prevent_thread_lock=True, share=not ui.is_space)
36
+ self.gradio_app = ui
37
  self.fastapi_app = app
38
  self.webhook_paths: Set[str] = set()
39
 
ui.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from enum import Enum
3
+ from database import is_space_existing, is_space_registered, update_status
4
+
5
+
6
+ TITLE = "⚙️ Spaces CI Bot ⚙️"
7
+ DESCRIPTION = """
8
+ This app lets you register your Space with the Spaces CI Bot.
9
+
10
+ Once your repository is watched, any PR opened on your Space will be deployed as a temporary Space to test the changes
11
+ on your demo. Any changes pushed to the PRs will trigger a re-deployment. Once the PR is merged, the temporary Space is
12
+ deleted.
13
+
14
+ If your app needs some secrets to run or a specific hardware, you will need to duplicate the temporary Space and to
15
+ setup your environment.
16
+ """
17
+
18
+
19
+ class Action(Enum):
20
+ REGISTER = "Enable CI Bot"
21
+ UNREGISTER = "Disable CI Bot"
22
+ CHECK_STATUS = "Check status"
23
+
24
+
25
+ def gradio_fn(space_id: str, action: str) -> str:
26
+ if not is_space_existing(space_id):
27
+ return f"""## Error
28
+ Could not find Space '**{space_id}**' on the Hub.
29
+ Please make sure you are trying to register a public repository.
30
+ """
31
+
32
+ registered = is_space_registered(space_id)
33
+ if action == Action.REGISTER.value:
34
+ if registered:
35
+ return f"""## Did nothing
36
+ The Space '**{space_id}**' is already in the watchlist. Any PR opened on
37
+ this repository will trigger an ephemeral Space.
38
+ """
39
+ else:
40
+ update_status(space_id, should_watch=True)
41
+ return f"""## Success
42
+ The Space '**{space_id}**' has been added to the watchlist. Any PR opened on
43
+ this repository will trigger an ephemeral Space.
44
+ """
45
+ elif action == Action.UNREGISTER.value:
46
+ if not registered:
47
+ return f"""## Did nothing
48
+ The Space '**{space_id}**' is currently not in the watchlist.
49
+ """
50
+ else:
51
+ update_status(space_id, should_watch=False)
52
+ return f"""## Success
53
+ The Space '**{space_id}**' has been removed from the watchlist.
54
+ """
55
+ elif action == Action.CHECK_STATUS.value:
56
+ if registered:
57
+ return f"""## Watched
58
+ The Space '**{space_id}**' is already in the watchlist. Any PR opened on
59
+ this repository will trigger an ephemeral Space.
60
+ """
61
+ else:
62
+ return f"""## Not watched
63
+ The Space '**{space_id}**' is currently not in the watchlist.
64
+ """
65
+ else:
66
+ return f"**Error:** action {action} not implemented."
67
+
68
+
69
+ def generate_ui() -> gr.Blocks:
70
+ return gr.Interface(
71
+ fn=gradio_fn,
72
+ inputs=[
73
+ gr.Textbox(lines=1, placeholder="username/my_cool_space", label="Space ID"),
74
+ gr.Radio(
75
+ [action.value for action in Action],
76
+ value=Action.REGISTER.value,
77
+ label="What should I do?",
78
+ ),
79
+ ],
80
+ outputs=[gr.Markdown()],
81
+ title=TITLE,
82
+ description=DESCRIPTION,
83
+ allow_flagging="never",
84
+ )