| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Contains commands to interact with discussions and pull requests on the Hugging Face Hub.""" |
|
|
| import enum |
| import sys |
| from pathlib import Path |
| from typing import Annotated |
|
|
| import typer |
|
|
| from huggingface_hub import constants |
|
|
| from ._cli_utils import ( |
| AuthorOpt, |
| FormatWithAutoOpt, |
| LimitOpt, |
| RepoIdArg, |
| RepoType, |
| RepoTypeOpt, |
| TokenOpt, |
| api_object_to_dict, |
| get_hf_api, |
| typer_factory, |
| ) |
| from ._output import OutputFormatWithAuto, out |
|
|
|
|
| class DiscussionStatus(str, enum.Enum): |
| open = "open" |
| closed = "closed" |
| merged = "merged" |
| draft = "draft" |
| all = "all" |
|
|
|
|
| class DiscussionKind(str, enum.Enum): |
| all = "all" |
| discussion = "discussion" |
| pull_request = "pull_request" |
|
|
|
|
| |
| |
| |
| |
| _CLIENT_SIDE_STATUSES = {"merged", "draft"} |
|
|
|
|
| DiscussionNumArg = Annotated[ |
| int, |
| typer.Argument( |
| help="The discussion or pull request number.", |
| min=1, |
| ), |
| ] |
|
|
|
|
| def _read_body(body: str | None, body_file: Path | None) -> str | None: |
| """Resolve body text from --body or --body-file (supports '-' for stdin).""" |
| if body is not None and body_file is not None: |
| raise typer.BadParameter("Cannot use both --body and --body-file.") |
| if body_file is not None: |
| if str(body_file) == "-": |
| return sys.stdin.read() |
| return body_file.read_text(encoding="utf-8") |
| return body |
|
|
|
|
| discussions_cli = typer_factory(help="Manage discussions and pull requests on the Hub.") |
|
|
|
|
| @discussions_cli.command( |
| "list | ls", |
| examples=[ |
| "hf discussions list username/my-model", |
| "hf discussions list username/my-model --kind pull_request --status merged", |
| "hf discussions list username/my-dataset --type dataset --status closed", |
| "hf discussions list username/my-model --author alice --format json", |
| ], |
| ) |
| def discussion_list( |
| repo_id: RepoIdArg, |
| status: Annotated[ |
| DiscussionStatus, |
| typer.Option( |
| "-s", |
| "--status", |
| help="Filter by status (open, closed, merged, draft, all).", |
| ), |
| ] = DiscussionStatus.open, |
| kind: Annotated[ |
| DiscussionKind, |
| typer.Option( |
| "-k", |
| "--kind", |
| help="Filter by kind (discussion, pull_request, all).", |
| ), |
| ] = DiscussionKind.all, |
| author: AuthorOpt = None, |
| limit: LimitOpt = 30, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """List discussions and pull requests on a repo.""" |
| api = get_hf_api(token=token) |
|
|
| api_status: constants.DiscussionStatusFilter | None |
| if status == DiscussionStatus.open: |
| api_status = "open" |
| elif status == DiscussionStatus.closed: |
| api_status = "closed" |
| else: |
| api_status = None |
|
|
| api_discussion_type: constants.DiscussionTypeFilter | None |
| if kind == DiscussionKind.all: |
| api_discussion_type = None |
| else: |
| api_discussion_type = kind.value |
|
|
| discussions = [] |
| for d in api.get_repo_discussions( |
| repo_id=repo_id, |
| author=author, |
| discussion_type=api_discussion_type, |
| discussion_status=api_status, |
| repo_type=repo_type.value, |
| ): |
| if status.value in _CLIENT_SIDE_STATUSES and d.status != status.value: |
| continue |
| discussions.append(d) |
| if len(discussions) >= limit: |
| break |
|
|
| items = [api_object_to_dict(d) for d in discussions] |
| out.table( |
| items, |
| headers=["num", "title", "is_pull_request", "status", "author", "created_at"], |
| id_key="num", |
| alignments={"num": "right"}, |
| ) |
|
|
|
|
| @discussions_cli.command( |
| "info", |
| examples=[ |
| "hf discussions info username/my-model 5", |
| "hf discussions info username/my-model 5 --format json", |
| ], |
| ) |
| def discussion_info( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Get info about a discussion or pull request.""" |
| api = get_hf_api(token=token) |
| details = api.get_discussion_details( |
| repo_id=repo_id, |
| discussion_num=num, |
| repo_type=repo_type.value, |
| ) |
| out.dict(details) |
|
|
|
|
| @discussions_cli.command( |
| "create", |
| examples=[ |
| 'hf discussions create username/my-model --title "Bug report"', |
| 'hf discussions create username/my-model --title "Feature request" --body "Please add X"', |
| 'hf discussions create username/my-model --title "Fix typo" --pull-request', |
| 'hf discussions create username/my-dataset --type dataset --title "Data quality issue"', |
| ], |
| ) |
| def discussion_create( |
| repo_id: RepoIdArg, |
| title: Annotated[ |
| str, |
| typer.Option( |
| "--title", |
| help="The title of the discussion or pull request.", |
| ), |
| ], |
| body: Annotated[ |
| str | None, |
| typer.Option( |
| "--body", |
| help="The description (supports Markdown).", |
| ), |
| ] = None, |
| body_file: Annotated[ |
| Path | None, |
| typer.Option( |
| "--body-file", |
| help="Read the description from a file. Use '-' for stdin.", |
| ), |
| ] = None, |
| pull_request: Annotated[ |
| bool, |
| typer.Option( |
| "--pull-request", |
| "--pr", |
| help="Create a pull request instead of a discussion.", |
| ), |
| ] = False, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Create a new discussion or pull request on a repo.""" |
| description = _read_body(body, body_file) |
| api = get_hf_api(token=token) |
| discussion = api.create_discussion( |
| repo_id=repo_id, |
| title=title, |
| description=description, |
| repo_type=repo_type.value, |
| pull_request=pull_request, |
| ) |
| kind = "pull request" if pull_request else "discussion" |
| ref = f"refs/pr/{discussion.num}" if pull_request else None |
| out.result(f"Created {kind} #{discussion.num} on {repo_id}", num=discussion.num, url=discussion.url, ref=ref) |
|
|
|
|
| @discussions_cli.command( |
| "comment", |
| examples=[ |
| 'hf discussions comment username/my-model 5 --body "Thanks for reporting!"', |
| 'hf discussions comment username/my-model 5 --body "LGTM!"', |
| ], |
| ) |
| def discussion_comment( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| body: Annotated[ |
| str | None, |
| typer.Option( |
| "--body", |
| help="The comment text (supports Markdown).", |
| ), |
| ] = None, |
| body_file: Annotated[ |
| Path | None, |
| typer.Option( |
| "--body-file", |
| help="Read the comment from a file. Use '-' for stdin.", |
| ), |
| ] = None, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Comment on a discussion or pull request.""" |
| comment = _read_body(body, body_file) |
| if comment is None: |
| raise typer.BadParameter("Either --body or --body-file is required.") |
| api = get_hf_api(token=token) |
| api.comment_discussion( |
| repo_id=repo_id, |
| discussion_num=num, |
| comment=comment, |
| repo_type=repo_type.value, |
| ) |
| out.result(f"Commented on #{num} in {repo_id}", num=num, repo=repo_id) |
|
|
|
|
| @discussions_cli.command( |
| "close", |
| examples=[ |
| "hf discussions close username/my-model 5", |
| 'hf discussions close username/my-model 5 --comment "Closing as resolved."', |
| ], |
| ) |
| def discussion_close( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| comment: Annotated[ |
| str | None, |
| typer.Option( |
| "--comment", |
| help="An optional comment to post when closing.", |
| ), |
| ] = None, |
| yes: Annotated[ |
| bool, |
| typer.Option( |
| "--yes", |
| "-y", |
| help="Skip confirmation prompt.", |
| ), |
| ] = False, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Close a discussion or pull request.""" |
| out.confirm(f"Close #{num} on '{repo_id}'?", yes=yes) |
| api = get_hf_api(token=token) |
| api.change_discussion_status( |
| repo_id=repo_id, |
| discussion_num=num, |
| new_status="closed", |
| comment=comment, |
| repo_type=repo_type.value, |
| ) |
| out.result(f"Closed #{num} in {repo_id}", num=num, repo=repo_id) |
|
|
|
|
| @discussions_cli.command( |
| "reopen", |
| examples=[ |
| "hf discussions reopen username/my-model 5", |
| 'hf discussions reopen username/my-model 5 --comment "Reopening for further investigation."', |
| ], |
| ) |
| def discussion_reopen( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| comment: Annotated[ |
| str | None, |
| typer.Option( |
| "--comment", |
| help="An optional comment to post when reopening.", |
| ), |
| ] = None, |
| yes: Annotated[ |
| bool, |
| typer.Option( |
| "--yes", |
| "-y", |
| help="Skip confirmation prompt.", |
| ), |
| ] = False, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Reopen a closed discussion or pull request.""" |
| out.confirm(f"Reopen #{num} on '{repo_id}'?", yes=yes) |
| api = get_hf_api(token=token) |
| api.change_discussion_status( |
| repo_id=repo_id, |
| discussion_num=num, |
| new_status="open", |
| comment=comment, |
| repo_type=repo_type.value, |
| ) |
| out.result(f"Reopened #{num} in {repo_id}", num=num, repo=repo_id) |
|
|
|
|
| @discussions_cli.command( |
| "rename", |
| examples=[ |
| 'hf discussions rename username/my-model 5 "Updated title"', |
| ], |
| ) |
| def discussion_rename( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| new_title: Annotated[ |
| str, |
| typer.Argument( |
| help="The new title.", |
| ), |
| ], |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Rename a discussion or pull request.""" |
| api = get_hf_api(token=token) |
| api.rename_discussion( |
| repo_id=repo_id, |
| discussion_num=num, |
| new_title=new_title, |
| repo_type=repo_type.value, |
| ) |
| out.result(f"Renamed #{num} in {repo_id}", num=num, repo=repo_id, title=new_title) |
|
|
|
|
| @discussions_cli.command( |
| "merge", |
| examples=[ |
| "hf discussions merge username/my-model 5", |
| 'hf discussions merge username/my-model 5 --comment "Merging, thanks!"', |
| ], |
| ) |
| def discussion_merge( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| comment: Annotated[ |
| str | None, |
| typer.Option( |
| "--comment", |
| help="An optional comment to post when merging.", |
| ), |
| ] = None, |
| yes: Annotated[ |
| bool, |
| typer.Option( |
| "--yes", |
| "-y", |
| help="Skip confirmation prompt.", |
| ), |
| ] = False, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Merge a pull request.""" |
| out.confirm(f"Merge #{num} on '{repo_id}'?", yes=yes) |
| api = get_hf_api(token=token) |
| api.merge_pull_request( |
| repo_id=repo_id, |
| discussion_num=num, |
| comment=comment, |
| repo_type=repo_type.value, |
| ) |
| out.result(f"Merged #{num} in {repo_id}", num=num, repo=repo_id) |
|
|
|
|
| @discussions_cli.command( |
| "diff", |
| examples=[ |
| "hf discussions diff username/my-model 5", |
| ], |
| ) |
| def discussion_diff( |
| repo_id: RepoIdArg, |
| num: DiscussionNumArg, |
| repo_type: RepoTypeOpt = RepoType.model, |
| format: FormatWithAutoOpt = OutputFormatWithAuto.auto, |
| token: TokenOpt = None, |
| ) -> None: |
| """Show the diff of a pull request.""" |
| api = get_hf_api(token=token) |
| details = api.get_discussion_details( |
| repo_id=repo_id, |
| discussion_num=num, |
| repo_type=repo_type.value, |
| ) |
| if details.diff: |
| out.text(details.diff) |
| else: |
| out.text("No diff available.") |
|
|