[Discussions] Add `upload_attachment` and `--attach` for media files

1 hour ago

This pull request lets you attach an image, a video or an audio file to a Hub discussion or pull request, straight from Python or the terminal. There's one new method on HfApi, and a new attach option on three discussions commands.

Ask about this presentation

Chapters

  1. 0:00Attach media from Python and the CLI
  2. 0:16Calling /uploads by hand
  3. 0:36HfApi.upload_attachment
  4. 0:58Supported formats
  5. 1:16The --attach option
  6. 1:33How the body is rewritten
  7. 1:52Markdown per media type
  8. 2:13Every file is checked first
  9. 2:33What review caught
  10. 2:56The author's run
  11. 3:19Limits and where to start
Show transcript

Attach media from Python and the CLI

huggingface_hub

Attach screenshots and videos to Hub discussions from Python and the CLI

$ hf discussions comment username/my-model 5 --attach demo.mp4

This pull request lets you attach an image, a video or an audio file to a Hub discussion or pull request, straight from Python or the terminal. There's one new method on HfApi, and a new attach option on three discussions commands.

Calling /uploads by hand

huggingface_hub

Until now, attaching a file from code meant calling the undocumented /uploads endpoint by hand

Before uploads/noauth/…
Now uploads/<userId>/… uploads are attributed to the user

The Hub web editor uploads a file when you drag it into a comment box. From Python or the command line, you had to call the undocumented uploads endpoint yourself. Going through the client with the usual auth headers also fixes attribution. Uploads now land under your user ID, instead of under no-auth.

HfApi.upload_attachment

huggingface_hub

upload_attachment(path) returns the file's CDN URL

src/huggingface_hub/hf_api.py
def upload_attachment(self, path: str | Path, *, token: bool | str | None = None) -> str:
path = Path(path) content_type = _ATTACHMENT_CONTENT_TYPES.get(path.suffix.lower()) if content_type is None: raise ValueError(f"Cannot upload '{path}': supported formats are …")
headers = self._build_hf_headers(token=token) headers["Content-Type"] = content_type resp = get_session().post( f"{self.endpoint}/uploads", headers=headers, content=path.read_bytes() ) hf_raise_for_status(resp) return resp.text.strip()

Here's the new method in hf_api.py. It takes a path and an optional token, and returns a string. It looks up a content type from the file extension, and raises a value error for any format the Hub editor doesn't accept. Then it posts the raw bytes to the uploads endpoint and returns the response body, which is the file's URL.

Supported formats

huggingface_hub

Ten file extensions, the same list the Hub web editor accepts

Image
.png .jpg .jpeg
.gif .webp
Video
.mp4 .mov
.webm
Audio
.mp3
.wav

Files up to 10 MB, or 50 MB for video

The allowed formats live in one dictionary, mapping each extension to its content type. Five image extensions, three for video and two for audio, matching what the web editor takes. The documented limit is 10 megabytes per file, or 50 megabytes for a video.

The --attach option

huggingface_hub

Three discussions commands take a repeatable --attach option

hf discussions create hf discussions comment hf discussions edit
--attach "./after.png#After the fix"
a file path, then # and the alt text for an appended image

On the command line, create, including with the pull request option, comment and edit all take an attach option you can repeat, modeled on the attach flag in GitHub's own CLI. Each value is a file path, optionally followed by a hash sign and the alt text for that image.

How the body is rewritten

huggingface_hub

A reference in the body points to the upload, and other files go at the end

You write
--body "Before: ![Before the fix](./before.png)" --attach ./before.png --attach "./after.png#After the fix"
The Hub receives
Before: ![Before the fix](https://cdn-uploads.huggingface.co/…/QdFSiYU0Plm7GM3kGSipw.png) ![After the fix](https://cdn-uploads.huggingface.co/…/efw80nbXnJzUL5-rJXLo0.png)

Here's what attach does to the body. You write an ordinary markdown image pointing at a local file, and pass that file to attach, along with a second one. The local path is swapped for the uploaded URL. The second file had no reference, so it's appended at the end, with the alt text from after the hash.

Markdown per media type

huggingface_hub

Appended files get the markdown the Hub web editor writes

Image
![alt](url)
Audio
<audio controls src="url"></audio>
Video
url
the Hub renders it as a player

Appended files get the same markdown the web editor inserts. An image becomes an image embed, and an audio file becomes an audio tag with controls. A video stays a bare URL, because the Hub turns any uploaded video URL into a player, so a video referenced in the body is rewritten the same way.

Every file is checked first

huggingface_hub

The CLI checks every attached file before it uploads any of them

src/huggingface_hub/cli/discussions.py · _attach_files
for value in attach: ... if not path.is_file(): raise CLIError(f"Attachment not found: '{file}'.") if path.suffix.lower() not in _ATTACHMENT_CONTENT_TYPES: raise CLIError(f"Cannot attach '{file}': supported formats are …") alts[path] = alt or path.stem
urls = {path: api.upload_attachment(path) for path in alts}

hf discussions comment now runs with --attach and no --body

Here's the loop that reads the attach values. It checks every one of them first, and stops with an error if a file is missing or its type isn't on the list. Only then does it upload, so a typo in the third file stops the command before the first two are uploaded. And comment now runs with attach alone, without a body.

What review caught

huggingface_hub

Review caught paths with spaces, which the merged regex handles, and tilde paths, which stay as written

Medium · fixed before merge
Paths with spaces skipped the rewrite
The merged pattern accepts spaces, <path> and balanced parentheses, and unquote() decodes %20.
Low · unchanged at merge
A quoted ~/ path is not expanded
Neither --attach nor upload_attachment calls expanduser().

Cursor's Bugbot raised two issues in review. An image reference with spaces, angle brackets or a percent-encoded path didn't match its file. The merged pattern accepts all three, and decodes the path before matching. A quoted path starting with a tilde isn't expanded, and that's still true in the merged code. Write the full path whenever you quote a value to add alt text.

The author's run

huggingface_hub

One command opened a pull request with both images embedded

$ hf discussions create celinah/hfh-attach-test --title "Fix rendering" --pr \ --body "Before: ![Before the fix](./before.png)" \ --attach ./before.png --attach "./after.png#After the fix" num=1 url=https://huggingface.co/celinah/hfh-attach-test/discussions/1 ref=refs/pr/1
Before the fix
![Before the fix] · referenced
After the fix
![After the fix] · appended
Both served from cdn-uploads.huggingface.co under the author's user ID

Here's the author's own run against a test repository. One create command, with the pull request option and two attach values, opens pull request number one. Both files landed on the Hub's CDN under the author's user ID. These are the two test images as uploaded: the first replaced its reference in the body, and the second was appended.

Limits and where to start

huggingface_hub

Put the before and after in the pull request itself with --attach

Only ![alt](path) embeds are rewritten, by a regex rather than a markdown parser
hf upload --create-pr is left for a later change
# Guide: docs/source/en/guides/cli.md, "Attach images and videos" $ hf discussions comment username/my-model 5 --body "Here is a demo:" --attach demo.mp4

Two limits are deliberate. Only image-style embeds are rewritten, using a regex instead of a markdown parser, and adding attachments to the upload command's create pull request option is left for a later change. The CLI guide's new section, Attach images and videos, has the examples. Next time a fix changes how something looks, put the before and after in the pull request itself, with attach.

What happened in AI today, in your inbox

One short email each evening. Read it and you’re caught up.

Built in New York with ❤️