mirror of
https://github.com/huggingface/diffusers.git
synced 2026-10-24 20:59:27 +03:00
Update diffusers-cli for agentic use (#13966)
* update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * pdate * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update * update --------- Co-authored-by: Sayak Paul <spsayakpaul@gmail.com>
This commit is contained in:
157
.ai/skills/custom-blocks/SKILL.md
Normal file
157
.ai/skills/custom-blocks/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: custom-blocks
|
||||
description: >
|
||||
Use when the user has written (or wants to write) a `ModularPipelineBlocks`
|
||||
subclass in a local Python file and needs to package it into a Hub-uploadable
|
||||
directory. Covers the workflow from a single `block.py` file to a published
|
||||
custom-block repo that consumers can load via
|
||||
`ModularPipeline.from_pretrained(<repo>, trust_remote_code=True)`.
|
||||
---
|
||||
|
||||
## What this skill is for
|
||||
|
||||
A `ModularPipelineBlocks` subclass is a unit of pipeline logic — input/output spec plus a `__call__` — that
|
||||
slots into diffusers' modular pipeline composition. Once you have one defined locally, you almost always want to
|
||||
publish it as a small Hub repo so others can `from_pretrained` it. `diffusers-cli custom_blocks` automates the
|
||||
packaging step: it parses your Python file, instantiates the chosen block class, and writes a
|
||||
`save_pretrained`-style directory in your cwd that's ready to push to the Hub.
|
||||
|
||||
Use this skill when:
|
||||
|
||||
- The user is writing a custom modular block and asks "how do I publish this?" or "package this for the Hub".
|
||||
- The user has a `block.py` (or similar) file with one or more `ModularPipelineBlocks` subclasses.
|
||||
- You're scaffolding a new modular pipeline repo and need the on-disk layout that `ModularPipelineBlocks.from_pretrained`
|
||||
expects.
|
||||
|
||||
Don't use this skill for: running an existing modular pipeline (`diffusers-cli run`), introspecting one
|
||||
(`diffusers-cli schema`), or writing the block class itself — this skill packages an *already-written* block.
|
||||
|
||||
## The end-to-end workflow
|
||||
|
||||
```
|
||||
[you: write block.py] → diffusers-cli custom_blocks → [packaged dir in cwd]
|
||||
↓
|
||||
hf upload <repo> .
|
||||
↓
|
||||
consumers: ModularPipeline.from_pretrained(<repo>, trust_remote_code=True)
|
||||
diffusers-cli schema --model <repo> --trust-remote-code
|
||||
diffusers-cli run --model <repo> --trust-remote-code ...
|
||||
```
|
||||
|
||||
The skill covers the middle box. The bookends (writing the block and uploading) are out of scope.
|
||||
|
||||
## Command surface
|
||||
|
||||
```bash
|
||||
diffusers-cli custom_blocks [--block_module_name <file.py>] [--block_class_name <ClassName>]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
- `--block_module_name <file>` — Python file containing the block class. Defaults to `block.py` in the cwd.
|
||||
- `--block_class_name <name>` — Which class in the file to package. Optional: if omitted, the CLI parses the
|
||||
file with `ast`, finds every class that inherits from `ModularPipelineBlocks`, and uses the first one (with
|
||||
an info log naming the others). Specify explicitly when the file defines more than one block and you want a
|
||||
specific one.
|
||||
|
||||
### What it does
|
||||
|
||||
1. **AST scan**: parses `<file>` without executing it, walks top-level `ClassDef` nodes, and collects every
|
||||
class whose `bases` include `ModularPipelineBlocks`.
|
||||
2. **Pick a class**: uses `--block_class_name` if given, else the first found. Errors with the list of available
|
||||
classes if your name doesn't match.
|
||||
3. **Load and save**: imports the file via `importlib.util.spec_from_file_location` (this does execute the
|
||||
module — make sure your block.py is something you trust to run), instantiates the chosen class with no
|
||||
constructor args, and calls `.save_pretrained(os.getcwd())`.
|
||||
|
||||
The result is a Hub-uploadable directory laid out the way `ModularPipelineBlocks.from_pretrained` expects:
|
||||
your block source, an `auto_map` in the config so consumers know to load it with `trust_remote_code=True`,
|
||||
and any artifacts `save_pretrained` writes for that block class.
|
||||
|
||||
## End-to-end example
|
||||
|
||||
Given a `block.py` like:
|
||||
|
||||
```python
|
||||
from diffusers.modular_pipelines import ModularPipelineBlocks, InputParam, OutputParam
|
||||
|
||||
class MyDenoiseBlock(ModularPipelineBlocks):
|
||||
model_name = "my-denoise"
|
||||
|
||||
@property
|
||||
def inputs(self):
|
||||
return [
|
||||
InputParam("latents", type_hint="torch.Tensor", required=True, description="Noisy latents."),
|
||||
InputParam("guidance_scale", type_hint="float", default=7.5),
|
||||
]
|
||||
|
||||
@property
|
||||
def intermediate_outputs(self):
|
||||
return [OutputParam("latents", type_hint="torch.Tensor")]
|
||||
|
||||
def __call__(self, components, state):
|
||||
# ... denoising logic ...
|
||||
return components, state
|
||||
```
|
||||
|
||||
Package it:
|
||||
|
||||
```bash
|
||||
diffusers-cli custom_blocks --block_module_name block.py
|
||||
```
|
||||
|
||||
Output in cwd:
|
||||
|
||||
```
|
||||
./
|
||||
├── block.py
|
||||
├── modular_config.json # contains auto_map → MyDenoiseBlock
|
||||
└── (any state files MyDenoiseBlock.save_pretrained writes)
|
||||
```
|
||||
|
||||
Upload to the Hub:
|
||||
|
||||
```bash
|
||||
hf upload my-user/my-denoise-block .
|
||||
```
|
||||
|
||||
Consumers can now use it:
|
||||
|
||||
```python
|
||||
from diffusers import ModularPipeline
|
||||
pipe = ModularPipeline.from_pretrained("my-user/my-denoise-block", trust_remote_code=True)
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
|
||||
```bash
|
||||
diffusers-cli schema --model my-user/my-denoise-block --trust-remote-code
|
||||
diffusers-cli run --model my-user/my-denoise-block --trust-remote-code \
|
||||
--pipeline-kwargs '{"latents": "...", "guidance_scale": 7.5}'
|
||||
```
|
||||
|
||||
## Common errors
|
||||
|
||||
- **`Could not parse '<file>': SyntaxError`** — the file isn't valid Python. Fix the syntax; the AST step runs
|
||||
before any execution.
|
||||
- **`block_class_name could not be retrieved. Available classes from <file>: [ClassA, ClassB]`** — your
|
||||
`--block_class_name` doesn't match any `ModularPipelineBlocks` subclass found. Pick from the list shown.
|
||||
- **No classes found**: silent — the command will try to use the first entry in an empty list and raise
|
||||
`IndexError`. If you hit that, double-check your class actually inherits from `ModularPipelineBlocks`
|
||||
(the AST scan looks for that literal base-class name; aliased imports like `from diffusers import ...
|
||||
as MPB` won't be picked up).
|
||||
- **Block requires constructor args**: the command calls `<ClassName>()` with no args. If your block needs
|
||||
`__init__` parameters, refactor to take them from `state`/`components` at `__call__` time instead, or
|
||||
hardcode defaults in `__init__`.
|
||||
|
||||
## Verifying the install
|
||||
|
||||
If `diffusers-cli` isn't on PATH, see the install verification section of
|
||||
[`../diffusers-cli/SKILL.md`](../diffusers-cli/SKILL.md#verifying-the-cli-is-installed).
|
||||
|
||||
## Related
|
||||
|
||||
- [`diffusers-cli` skill](../diffusers-cli/SKILL.md) — once your block is uploaded, `schema`/`run`
|
||||
let you call it from the terminal without writing Python.
|
||||
- diffusers' [modular pipelines docs](../../../docs/source/en/modular_diffusers) — for writing the block
|
||||
class itself.
|
||||
68
.ai/skills/diffusers-cli/SKILL.md
Normal file
68
.ai/skills/diffusers-cli/SKILL.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: diffusers-cli
|
||||
description: >
|
||||
Use when the user wants to run a diffusers pipeline from a terminal (one-off
|
||||
generation, batch jobs, smoke-testing a new model), run on HF Sandbox
|
||||
hardware via `--remote`, introspect a pipeline's input schema before
|
||||
calling it, or attach a LoRA at inference time. Prefer this over writing
|
||||
ad-hoc Python scripts for generation tasks.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`diffusers-cli` is the shipped CLI in `src/diffusers/commands/`. Subcommands relevant to agentic use:
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `run` | Run any `DiffusionPipeline` or `ModularPipeline`. Forwards `--pipeline-kwargs` verbatim, saves output by detecting its runtime type, optionally runs on HF Jobs via `--remote`. |
|
||||
| `schema` | Print the input schema for a pipeline repo (kwarg names, types, defaults, descriptions). **No weights downloaded** — only the small index file. |
|
||||
| `custom_blocks` | Package a local `ModularPipelineBlocks` subclass for the Hub. |
|
||||
| `env` | Print versions of diffusers + torch + transformers + accelerate + safetensors + CUDA + GPU info. Use when investigating environment issues, dtype/precision support, or building bug reports. |
|
||||
|
||||
## When to read which file
|
||||
|
||||
Most agentic work goes through `run`. Read the matching reference file before constructing a command:
|
||||
|
||||
- **[`run.md`](run.md)** — full reference for `diffusers-cli run`. Covers `--pipeline-kwargs`
|
||||
semantics and the shell-quoting gotcha, LoRA via `--lora`, optimization flags (`--dtype`, `--cpu-offload`,
|
||||
`--attention-backend`, `--vae-tiling/slicing`), output handling and `--push-to` bucket uploads, the full
|
||||
`--remote` HF Jobs flow (image, container command, log streaming, timing payload, artifact download), and
|
||||
context parallel (`--context-parallel`) for both local-torchrun and `--remote` paths.
|
||||
|
||||
The other commands are small enough that `diffusers-cli <command> --help` is the canonical reference:
|
||||
|
||||
```bash
|
||||
diffusers-cli schema --help
|
||||
diffusers-cli custom_blocks --help
|
||||
diffusers-cli env --help
|
||||
```
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- Multi-stage workflows where you need intermediate tensor manipulation between pipelines → write Python.
|
||||
- Training or fine-tuning → CLI only covers inference.
|
||||
- Anything requiring `quantization_config` or other low-level loader knobs not exposed by the CLI flags → write
|
||||
Python. (`device_map` is exposed as `--device-map`; see [run.md](run.md#optimization-flags).)
|
||||
|
||||
## Verifying the CLI is installed
|
||||
|
||||
The console entry point is registered in `pyproject.toml` (`diffusers-cli =
|
||||
"diffusers.commands.diffusers_cli:main"`). If `diffusers-cli` is not on PATH after `pip install -e .`, reinstall
|
||||
with `pip install -e . --force-reinstall --no-deps` and check `which diffusers-cli`. If the installed binary is
|
||||
missing recent features (e.g. you see `unrecognized arguments: --lora`), reinstall.
|
||||
|
||||
## Output formats
|
||||
|
||||
`--format {auto, human, agent, json}` (top-level flag, must appear before the subcommand):
|
||||
|
||||
- **`human`** — plain-text indented output for terminals (default when not running under an agent harness). No ANSI color.
|
||||
- **`agent`** — TSV tables and `key=value` lines. Auto-selected when an agent env var is present
|
||||
(`CLAUDECODE`, `CLAUDE_CODE`, `CODEX_SANDBOX`, `CURSOR_AI`, `AIDER_AI_CONTEXT`, `GH_COPILOT_AGENT`,
|
||||
`AI_AGENT`). Token-cheap for LLM agents to read.
|
||||
- **`json`** — compact JSON. Use for programmatic parsing (scripts, services) where type fidelity and nested
|
||||
structures matter.
|
||||
|
||||
`stdout` carries data; `stderr` carries hints/warnings/progress — parseable output is never polluted.
|
||||
|
||||
Rule of thumb: `--format json` for scripts that will `json.loads()` the output, otherwise leave it on
|
||||
auto-detect (`agent` for LLMs, `human` for terminals).
|
||||
305
.ai/skills/diffusers-cli/run.md
Normal file
305
.ai/skills/diffusers-cli/run.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# `diffusers-cli run` — reference
|
||||
|
||||
Full surface for `diffusers-cli run`. Use this file as the source of truth when constructing a `run`
|
||||
invocation. The top-level [`SKILL.md`](SKILL.md) covers when to use the CLI; this file covers how.
|
||||
|
||||
## The schema → run flow
|
||||
|
||||
For any model you haven't called before, run `schema` first to learn its input contract, then `run` with
|
||||
the right `--pipeline-kwargs`:
|
||||
|
||||
```bash
|
||||
# 1. Discover what kwargs the pipeline takes (no weight download)
|
||||
diffusers-cli --format json schema --model black-forest-labs/FLUX.2-klein-9B
|
||||
|
||||
# 2. Run it
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "Make the cats fur grey", "image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png"}' \
|
||||
--dtype bf16
|
||||
```
|
||||
|
||||
`schema --format json` emits a `{task, model, pipeline_class, inputs[]}` payload where each input is
|
||||
`{name, type_hint, default, required, description}`.
|
||||
|
||||
## Standard vs modular detection
|
||||
|
||||
`run` auto-detects which kind of pipeline it's calling:
|
||||
|
||||
1. If `model_index.json` exists on the repo → `DiffusionPipeline.from_pretrained` path.
|
||||
2. Otherwise → `ModularPipeline.from_pretrained` path.
|
||||
|
||||
You don't need to tell it which. Modular repos must pass `--trust-remote-code` if they ship custom block code.
|
||||
|
||||
## `--pipeline-kwargs` semantics
|
||||
|
||||
A JSON object passed straight through to `pipeline(**kwargs)`. String values at known media-input keys are
|
||||
auto-loaded before the pipeline is called:
|
||||
|
||||
- **Images** (`image`, `mask_image`, `control_image`, `ip_adapter_image`, `image_2`) → `PIL.Image.Image`
|
||||
via `diffusers.utils.load_image`. Accepts URLs or local paths.
|
||||
- **Videos** (`video`, `control_video`) → `list[PIL.Image.Image]` via `diffusers.utils.load_video`.
|
||||
- **Audio** (`initial_audio_waveforms`, `reference_audio`, `src_audio`) → `torch.Tensor` via `torchaudio.load`.
|
||||
For `initial_audio_waveforms`, the file's native sample rate is auto-written to
|
||||
`initial_audio_sampling_rate` if you didn't pass it explicitly.
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png", "prompt": "make the fur grey", "strength": 0.6}'
|
||||
```
|
||||
|
||||
Media resolution runs **before** the pipeline weights load, so a dead URL or missing file fails within
|
||||
seconds instead of after a multi-minute model download.
|
||||
|
||||
**Batched inputs**: any media key (and `prompt`) accepts a JSON array — each string in the list is loaded
|
||||
individually, and the pipeline processes the whole list in one forward pass on the GPU. Use this when you
|
||||
have several inputs that share flavor/model/dtype and want them batched:
|
||||
|
||||
```bash
|
||||
diffusers-cli run --model black-forest-labs/FLUX.1-Kontext-dev \
|
||||
--pipeline-kwargs '{"prompt": ["make it grey", "make it pink", "make it blue"],
|
||||
"image": ["https://.../cat1.png", "https://.../cat2.png", "https://.../cat3.png"]}'
|
||||
```
|
||||
|
||||
**Shell-quoting gotcha**: the JSON must be on one line (or use `\` to line-continue). A literal newline inside the
|
||||
single-quoted argument lands as a raw control char inside the string and breaks `json.loads`.
|
||||
|
||||
## LoRA adapters (`--lora`)
|
||||
|
||||
Attach one or more LoRAs after the pipeline loads via a JSON spec. `--lora` accepts a single object or a list.
|
||||
|
||||
Single adapter:
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "a tiny grey cat"}' \
|
||||
--lora '{"lora_id": "alvdansen/littletinies", "lora_scale": 0.8}'
|
||||
```
|
||||
|
||||
Multiple stacked adapters:
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "a tiny grey cat"}' \
|
||||
--lora '[
|
||||
{"lora_id": "alvdansen/littletinies", "lora_scale": 0.5, "adapter_name": "style"},
|
||||
{"lora_id": "author/detail-boost", "lora_scale": 0.3}
|
||||
]'
|
||||
```
|
||||
|
||||
Per-entry fields: `lora_id` (required), `lora_scale` (optional, default `1.0`), `adapter_name` (optional; when
|
||||
omitted while stacking, auto-generated as `lora_0`, `lora_1`, …; single-entry defaults to `"default"`).
|
||||
|
||||
Each entry calls `pipeline.load_lora_weights(<lora_id>, adapter_name=<name>)`. After all adapters load, one
|
||||
`pipeline.set_adapters(names, adapter_weights=scales)` activates them together.
|
||||
|
||||
## Optimization flags
|
||||
|
||||
- `--dtype {auto, bf16, fp16, fp32, …}` — pipeline weight dtype. `bf16` is the right default for modern DiTs on
|
||||
A100/H100.
|
||||
- `--device-map <value>` — component placement, forwarded to `from_pretrained(device_map=...)`. Accepts a plain
|
||||
torch device (`cuda`, `cuda:0`, `cpu`, `mps`), the string `balanced` (auto-splits pipeline components across
|
||||
visible GPUs), or a JSON dict `{"transformer": "cuda:0", "vae": "cuda:1"}` for explicit per-component
|
||||
placement. Auto-detects if omitted (pinned to `cuda:$LOCAL_RANK` under torchrun). `balanced` and dict values
|
||||
are incompatible with `--cpu-offload`.
|
||||
- `--cpu-offload {model, group}` — `model` uses `enable_model_cpu_offload`, `group` uses
|
||||
`enable_group_offload(offload_type="leaf_level", use_stream=True)`. Use `group` to fit a 9B+ model on a single
|
||||
A100. Onload target device comes from `--device-map` (must be a plain device string in this case).
|
||||
- `--attention-backend {default, flash_hub, flash_varlen_hub, flash_4_hub, sage_hub}` — hub-hosted kernels,
|
||||
auto-downloaded on first use. Failures (kernel not available, CUDA arch mismatch, network) raise a clear
|
||||
`SystemExit` listing the alternatives instead of silently reverting to the default. Only supported on
|
||||
transformer-based pipelines; UNet pipelines get a `logger.warning` and the flag is ignored.
|
||||
- `--vae-tiling` / `--vae-slicing` — lower peak VAE decode VRAM.
|
||||
- `--compile [JSON]` — `torch.compile` every denoiser submodule. See [Compile](#compile) below.
|
||||
- `--context-parallel` — Ulysses-style context parallelism on a DiT. See [Context parallel](#context-parallel) below.
|
||||
|
||||
## Output handling
|
||||
|
||||
`run` detects the pipeline return type and saves accordingly:
|
||||
|
||||
- `PIL.Image` / list of them → `<NNNN>.png` (zero-padded, e.g. `0000.png`)
|
||||
- Frame sequence (≥2 PILs or ndarrays) → `0000.mp4` (uses `--fps`, default 8)
|
||||
- Numpy audio array → `0000.wav` (uses `--sampling-rate`)
|
||||
- Anything else → JSON dump
|
||||
|
||||
**Default output directory** is `~/.diffusers/cli/run/outputs/diffusers-run-<YYYYMMDDTHHMMSS>-<short-uuid>/`.
|
||||
Each run gets its own subdirectory so consecutive invocations don't overwrite each other. The same run id
|
||||
is used for the local dir, the sandbox's `DIFFUSERS_CLI_RUN_ID` env var, and the sandbox input/output paths
|
||||
under `--remote`, so a run is traceable end-to-end.
|
||||
|
||||
Override the destination with `--output <path>` (file or directory). Explicit `--output` bypasses the
|
||||
`diffusers-run-*` namespace — files land flat in the path you gave.
|
||||
|
||||
### `--push-to`
|
||||
|
||||
Upload outputs to an HF bucket. Objects land under `<bucket>/<run_id>/<filename>`. The bucket is created
|
||||
if it doesn't exist. Behavior interacts with `--output` and `--remote`:
|
||||
|
||||
| flags | download to local? | bucket write? |
|
||||
|---|---|---|
|
||||
| (neither) | ✅ `~/.diffusers/cli/run/outputs/<run_id>/` | — |
|
||||
| `--output /path` | ✅ `/path/` | — |
|
||||
| `--push-to my/bucket` | ❌ | ✅ (bucket only) |
|
||||
| `--push-to my/bucket --output /path` | ✅ | ✅ (both) |
|
||||
|
||||
The rule: explicit `--push-to` means "the bucket is my destination" — skip the local download unless the
|
||||
user also explicitly asked for a local target via `--output`.
|
||||
|
||||
## Remote execution (`--remote`)
|
||||
|
||||
Add `--remote` to run the same call inside a [Hugging Face Sandbox](https://huggingface.co/docs/huggingface_hub/en/guides/sandbox)
|
||||
— an isolated cloud VM (built on HF Jobs) the CLI drives over HTTP: it uploads inputs, installs deps, runs
|
||||
the pipeline, downloads outputs, then terminates the sandbox.
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "Make the cats fur grey", "image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png"}' \
|
||||
--remote --flavor a100-large \
|
||||
--dtype bf16 \
|
||||
--cpu-offload group
|
||||
```
|
||||
|
||||
What happens:
|
||||
|
||||
1. Your HF token is picked up (from `--token` or your login) and forwarded into the sandbox as `HF_TOKEN`.
|
||||
2. `--pipeline-kwargs` are parsed locally so JSON errors fail fast (no wasted sandbox time).
|
||||
3. A dedicated sandbox is created on `--flavor` from a pytorch image
|
||||
(`pytorch/pytorch:2.10.0-cuda12.8-cudnn9-runtime` by default) that already has torch + CUDA. Any local
|
||||
file paths in `--pipeline-kwargs` are uploaded into the sandbox under `/tmp/diffusers-cli/inputs/<run_id>/`
|
||||
via native file transfer (no bucket), and the JSON paths are rewritten to point at them.
|
||||
4. The small Python deps (`diffusers`, `accelerate`, `transformers`, `safetensors`, `sentencepiece`, `ftfy`)
|
||||
are installed with `uv pip install --system`. Output (install + run) streams live to your terminal.
|
||||
5. The sandbox CLI writes outputs to `/tmp/diffusers-cli/outputs/`; the CLI downloads every artifact back into
|
||||
the local target (see [`--push-to`](#-push-to) for when the download is skipped).
|
||||
6. The sandbox is terminated (unless `--keep-alive`/`--sandbox-id`), and the wallclock `run_seconds` for the
|
||||
pipeline command is printed and included in the JSON payload.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--flavor <name>` — sandbox hardware (e.g. `a10g-small`, `a100-large`, `4xa100-large`).
|
||||
- `--timeout <duration>` — max wallclock for the run command inside the sandbox (e.g. `30m`, `2h`). Defaults to `10m`.
|
||||
- `--dependencies <pkg>` — extra pip deps (repeatable). Appends to the defaults.
|
||||
- `--namespace <name>` — create the sandbox under a different account.
|
||||
- `--push-to <bucket>` — see [`--push-to`](#-push-to) above. The upload runs inside the sandbox; an explicit
|
||||
value with no `--output` makes the bucket the sole destination and skips the local download.
|
||||
- `--image <ref>` — override the sandbox image. Must ship torch + CUDA; the CLI installs the small Python
|
||||
deps on top via `uv pip install --system`. Useful for pinning a specific torch or bundling extra system libs.
|
||||
- `--volume <bucket-id>[:<mount-path>]` — mount an HF storage bucket into the sandbox as a read-write directory.
|
||||
Repeatable. Default mount is `/mnt/buckets/<bucket-id>`. Reference mounted files from `--pipeline-kwargs`
|
||||
like any other local path — no upload happens, the container reads straight from the FUSE mount. Applied
|
||||
only on new sandbox creation — ignored when reconnecting via `--sandbox-id`. Enables batch workflows:
|
||||
loop over bucket contents on the host, one `--sandbox-id` call per input.
|
||||
- `--keep-alive` — don't terminate the sandbox after the run; its id is printed so a later run can reconnect.
|
||||
- `--sandbox-id <id>` — reconnect to a kept-alive sandbox instead of creating a new one. Implies `--keep-alive`.
|
||||
- `--idle-timeout <duration>` — auto-shutdown after this much inactivity (default 10m). The billing backstop
|
||||
for kept-alive sandboxes. Only applied on new sandbox creation — ignored when reconnecting via `--sandbox-id`.
|
||||
|
||||
### Reusing a warm sandbox (`--keep-alive` / `--sandbox-id`)
|
||||
|
||||
By default each `--remote` run is ephemeral: create → run → download → kill. The cold cost (dep install +
|
||||
model weight download + any compile) is paid every time. For iterative work, keep one sandbox warm and reuse
|
||||
it — deps, the HF weight cache, and the `torch.compile` cache all survive on its disk:
|
||||
|
||||
```bash
|
||||
# First run: create + keep alive. Prints sandbox_id=<id>.
|
||||
diffusers-cli run -m black-forest-labs/FLUX.2-klein-9B --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "a cat"}' --remote --flavor a100-large --keep-alive
|
||||
|
||||
# Subsequent runs: reconnect — model already cached, only inference runs.
|
||||
diffusers-cli run -m black-forest-labs/FLUX.2-klein-9B --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "a dog"}' --remote --flavor a100-large --sandbox-id <id>
|
||||
|
||||
# Stop it when done (or let --idle-timeout reap it).
|
||||
hf sandbox kill <id>
|
||||
```
|
||||
|
||||
Notes on `--remote` argv forwarding: flags that control how the run is dispatched (`--flavor`, `--timeout`,
|
||||
`--namespace`, `--dependencies`, `--image`, `--keep-alive`, `--sandbox-id`, `--idle-timeout`, `--format`) are
|
||||
stripped before the argv is rebuilt for the sandbox CLI. Everything else — model, dtype, pipeline-kwargs,
|
||||
optimizations, output flags — is forwarded verbatim (`--output` is repointed at the sandbox output dir).
|
||||
|
||||
## Compile
|
||||
|
||||
`--compile` runs `torch.compile` over every `transformer*` / `unet*` submodule on the pipeline. Prefers
|
||||
regional compilation via `module.compile_repeated_blocks(**kwargs)` when the model exposes `_repeated_blocks`
|
||||
— this only compiles the repeated inner blocks (the bulk of the compute) rather than the whole module, so
|
||||
first-step latency is much lower. Falls back to full `torch.compile(module, **kwargs)` when no regional
|
||||
metadata is declared.
|
||||
|
||||
```bash
|
||||
# Bare — uses fullgraph=true
|
||||
diffusers-cli run --model <id> --dtype bf16 --compile --pipeline-kwargs '...'
|
||||
|
||||
# With kwargs forwarded to torch.compile
|
||||
diffusers-cli run --model <id> --dtype bf16 \
|
||||
--compile '{"mode": "max-autotune", "fullgraph": true}' \
|
||||
--pipeline-kwargs '...'
|
||||
```
|
||||
|
||||
**When it's worth it**: multi-step generation (~50+ denoising steps). You pay a one-time compilation cost on
|
||||
the first step, then every subsequent step is faster.
|
||||
|
||||
**Under `--remote`**: on an ephemeral sandbox the compile cache doesn't survive, so the compilation cost is
|
||||
paid on every run — `--compile` only breaks even on very long generations. On a `--keep-alive`/`--sandbox-id`
|
||||
sandbox the compile cache persists on disk, so the cost is paid once and reused: keep a warm sandbox and
|
||||
`--compile` pays off across runs.
|
||||
|
||||
`--compile` is **currently not supported with `--context-parallel`** — CP shards attention across ranks while
|
||||
regional compile assumes a stable single-device graph. If both are set, the CLI logs a warning and skips the
|
||||
compile step; CP still runs.
|
||||
|
||||
## Context parallel
|
||||
|
||||
`--context-parallel` enables Ulysses CP on a DiT-based pipeline. **Locally** the user must launch via torchrun:
|
||||
|
||||
```bash
|
||||
torchrun --nproc-per-node=2 -m diffusers.commands.diffusers_cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "Make the cats fur grey"}' \
|
||||
--dtype bf16 \
|
||||
--context-parallel
|
||||
```
|
||||
|
||||
**Remotely** the CLI handles the torchrun wrapping — just pass `--context-parallel` to a `--remote` invocation on
|
||||
a multi-GPU flavor:
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B \
|
||||
--pipeline-kwargs '{"prompt": "Make the cats fur grey", "image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png"}' \
|
||||
--remote --flavor 4xa100-large \
|
||||
--dtype bf16 \
|
||||
--context-parallel
|
||||
```
|
||||
|
||||
Inside the sandbox, CP swaps the entrypoint to `torchrun --nproc-per-node=gpu -m
|
||||
diffusers.commands.diffusers_cli`, initializes a hybrid process group (`cpu:gloo,cuda:nccl` — NCCL for the
|
||||
attention all-to-all, Gloo for `ulysses_anything`'s per-rank size coordination), pins each rank to
|
||||
`cuda:{LOCAL_RANK}`, and gates output saving/printing to rank 0 only.
|
||||
|
||||
**Memory note**: CP shards the sequence, **not the weights**. Every rank still holds the full transformer. Wins
|
||||
are wall-clock attention speedup and headroom for very long sequences, not "fit a model that doesn't fit." For
|
||||
weight sharding you'd want TP or FSDP — not exposed in the CLI yet.
|
||||
|
||||
CP is DiT-only. UNet pipelines raise a clear error directing you to a DiT pipeline (FLUX, SD3, HunyuanDiT,
|
||||
AuraFlow, …).
|
||||
|
||||
## Output mode (`--format`)
|
||||
|
||||
`--format` controls the shape of **stdout metadata** (which paths were written, timing, sandbox id, pushed
|
||||
bucket URLs) — **not** the media file format. Written images are always PNG, videos MP4, audio WAV; only the
|
||||
summary printed alongside them changes shape.
|
||||
|
||||
The CLI auto-detects when running under an AI coding agent (Claude Code, Cursor, Aider, GH Copilot Agent — via
|
||||
`CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AI`, `AIDER_AI_CONTEXT`, `GH_COPILOT_AGENT`) and switches to **agent
|
||||
mode** automatically — TSV tables, `key=value` results, compact JSON dicts, no progress bars.
|
||||
|
||||
Override explicitly with `--format {auto, human, agent, json, quiet}` placed **before** the subcommand:
|
||||
|
||||
```bash
|
||||
diffusers-cli --format json run --model <id> --pipeline-kwargs '...'
|
||||
```
|
||||
@@ -220,6 +220,8 @@
|
||||
title: Task recipes
|
||||
- local: using-diffusers/write_own_pipeline
|
||||
title: Understanding pipelines, models and schedulers
|
||||
- local: using-diffusers/cli
|
||||
title: Command line interface
|
||||
- local: community_projects
|
||||
title: Projects built with Diffusers
|
||||
- local: conceptual/philosophy
|
||||
|
||||
292
docs/source/en/using-diffusers/cli.md
Normal file
292
docs/source/en/using-diffusers/cli.md
Normal file
@@ -0,0 +1,292 @@
|
||||
<!--Copyright 2026 The HuggingFace Team. All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
|
||||
the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
|
||||
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations under the License.
|
||||
-->
|
||||
|
||||
# Command line interface
|
||||
|
||||
`diffusers-cli` is a command line client for running, inspecting, and packaging Diffusers pipelines.
|
||||
|
||||
## Available commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| [`env`](#env) | Print environment info for bug reports. |
|
||||
| [`schema`](#schema) | Inspect a pipeline's `__call__` signature without downloading weights. |
|
||||
| [`run`](#run) | Run a pipeline locally or in a Hugging Face Sandbox. |
|
||||
| [`custom_blocks`](#custom_blocks) | Package a local `ModularPipelineBlocks` subclass for the Hub. |
|
||||
| [`fp16_safetensors`](#fp16_safetensors) | Convert a checkpoint to fp16 `.safetensors`. |
|
||||
| [`skills`](#skills) | Install pre-authored skill bundles into your AI coding agent. |
|
||||
|
||||
> [!TIP]
|
||||
> This page does not provide details for all options under each subcommand. For the full, always-current list of options for any subcommand, run `diffusers-cli <command> --help` (`diffusers-cli run --help`).
|
||||
|
||||
## `env`
|
||||
|
||||
Prints Python/PyTorch/Diffusers versions, CUDA info, and installed optional deps. Use it when opening an
|
||||
issue so maintainers can reproduce your setup.
|
||||
|
||||
```bash
|
||||
diffusers-cli env
|
||||
```
|
||||
|
||||
## `schema`
|
||||
|
||||
Returns the pipeline's accepted inputs without downloading weights. This is useful when building `--pipeline-kwargs`
|
||||
|
||||
Only the index file is fetched. Standard pipelines read `model_index.json`; modular pipelines read
|
||||
`modular_model_index.json`; custom-block repos read `modular_config.json` and need `--trust-remote-code` since
|
||||
loading them runs code from the Hub.
|
||||
|
||||
```bash
|
||||
diffusers-cli --format json schema --model black-forest-labs/FLUX.1-dev
|
||||
diffusers-cli schema --model my-org/my-custom-blocks --trust-remote-code
|
||||
```
|
||||
|
||||
## `run`
|
||||
|
||||
Run a pipeline end-to-end. Auto-detects standard vs modular repos, auto-loads media inputs from URLs or local
|
||||
paths, saves outputs by detecting the pipeline's return type, and can run remotely on a Hugging Face
|
||||
Sandbox via `--remote`.
|
||||
|
||||
Minimal example:
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.1-dev \
|
||||
--dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "an astronaut riding a horse"}'
|
||||
```
|
||||
|
||||
### Passing pipeline arguments
|
||||
|
||||
`--pipeline-kwargs` takes a JSON object that's forwarded to `pipeline(**kwargs)`. String values at known
|
||||
media-input keys are auto-loaded:
|
||||
|
||||
- Images (`image`, `mask_image`, `control_image`, `ip_adapter_image`, `image_2`) → `PIL.Image` via
|
||||
`load_image`.
|
||||
- Videos (`video`, `control_video`) → `list[PIL.Image]` via `load_video`.
|
||||
- Audio (`initial_audio_waveforms`, `reference_audio`, `src_audio`) → `torch.Tensor` via `torchaudio.load`.
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.2-klein-9B --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "make the fur grey", "image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png", "strength": 0.6}'
|
||||
```
|
||||
|
||||
Both media keys and text keys accept a JSON array to run a batch through a single pipeline call. Each entry
|
||||
in a media list is loaded individually (URL, local path, or bucket-mount path), and diffusers processes the
|
||||
whole list in one forward pass on the GPU:
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.1-Kontext-dev --dtype bf16 \
|
||||
--pipeline-kwargs '{
|
||||
"prompt": ["make it grey", "make it pink", "make it blue"],
|
||||
"image": [
|
||||
"https://.../cat1.png",
|
||||
"https://.../cat2.png",
|
||||
"https://.../cat3.png"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Loading
|
||||
|
||||
Configure how the CLI loads model weights and custom pipeline code.
|
||||
|
||||
- `--dtype {auto, bfloat16, bf16, float16, fp16, float32, fp32}` — weight dtype.
|
||||
- `--device-map <value>` — component placement. Accepts a torch device string (`cuda`, `cuda:0`, `cpu`, `mps`),
|
||||
`balanced` (auto-splits components across visible GPUs), or a JSON dict for explicit per-component placement.
|
||||
Auto-detected if omitted. See [device_map](../training/distributed_inference#device_map) for more details
|
||||
- `--variant fp16` — pick a weight variant.
|
||||
- `--revision <sha>` — pin a specific model revision.
|
||||
- `--trust-remote-code` — allow custom code from the Hub (required for repos that ship custom pipeline classes
|
||||
or modular blocks). See [Community pipelines](../using-diffusers/custom_pipeline_overview) for standard custom
|
||||
pipelines and [Modular Diffusers](../modular_diffusers/overview).
|
||||
- `--lora <spec>` — attach a LoRA adapter after loading. Each value is a JSON dict; repeat the flag to
|
||||
stack multiple adapters. `lora_id` is required per entry; `lora_scale` defaults to `1.0`;
|
||||
`adapter_name` is optional (auto-generated as `lora_<i>` when stacking).
|
||||
- Single: `--lora '{"lora_id": "alvdansen/flux-koda", "lora_scale": 0.8}'`
|
||||
- Multiple: `--lora '{"lora_id": "alvdansen/flux-koda", "lora_scale": 0.6, "adapter_name": "koda"}' --lora '{"lora_id": "Shakker-Labs/FLUX.1-dev-LoRA-AntiBlur", "lora_scale": 0.4}'`
|
||||
|
||||
All specs are loaded by `pipeline.load_lora_weights(...)`, then activated together with a single
|
||||
`pipeline.set_adapters(names, adapter_weights=scales)` call. See [LoRA](../tutorials/using_peft_for_inference)
|
||||
for a deeper walkthrough of adapter stacking, scale scheduling, and hotswapping.
|
||||
|
||||
### Optimizations
|
||||
|
||||
- `--cpu-offload {model, group}` — `model` calls `enable_model_cpu_offload`; `group` calls
|
||||
`enable_group_offload(offload_type="leaf_level", use_stream=True)`. Onload target comes from `--device-map`
|
||||
(which must be a plain device string for offload). See
|
||||
[Model offloading](../optimization/memory#model-offloading) and
|
||||
[Group offloading](../optimization/memory#group-offloading).
|
||||
- `--attention-backend {default, flash_hub, flash_varlen_hub, flash_4_hub, sage_hub}` — Hub-hosted attention
|
||||
kernels, auto-downloaded on first use. Transformer-based pipelines only; ignored with a warning on legacy UNet
|
||||
pipelines. See [Attention backends](../optimization/attention_backends).
|
||||
- `--vae-tiling` / `--vae-slicing` — lower VAE decode VRAM. See
|
||||
[VAE tiling](../optimization/memory#vae-tiling) and [VAE slicing](../optimization/memory#vae-slicing).
|
||||
- `--compile [JSON]` — compile denoiser modules with [torch.compile](../optimization/fp16#torchcompile). The
|
||||
CLI prefers [regional compilation](../optimization/fp16#regional-compilation) for modules with repeated
|
||||
blocks. Bare `--compile` uses `fullgraph=true`. A JSON object is forwarded to `torch.compile`. Not supported
|
||||
with `--context-parallel`.
|
||||
- `--context-parallel` — Ulysses-style context parallelism on a DiT-based pipeline. Locally requires torchrun;
|
||||
under `--remote` the CLI wraps `torchrun --nproc-per-node=gpu` for you. See
|
||||
[Context parallelism](../training/distributed_inference#context-parallelism).
|
||||
|
||||
### Outputs
|
||||
|
||||
`run` detects the pipeline output type:
|
||||
|
||||
- `PIL.Image`/list → `<NNNN>.png` (zero-padded index, e.g. `0000.png`)
|
||||
- Image sequence → `0000.mp4` (`--fps` controls framerate, default 8)
|
||||
- Audio array → `0000.wav` (`--sampling-rate` controls rate)
|
||||
- Anything else → JSON dump
|
||||
|
||||
The default output directory format is `~/.diffusers/cli/run/outputs/diffusers-run-<YYYYMMDDTHHMMSS>-<uuid>/`. Each
|
||||
run gets its own subdirectory so consecutive invocations don't overwrite.
|
||||
|
||||
Override with `--output <path>`. How the path expands depends on its shape and the batch size:
|
||||
|
||||
| `--output` | 1 output | N outputs |
|
||||
|---|---|---|
|
||||
| _omitted_ | default dir → `0000.png` | default dir → `0000.png`, `0001.png`, `0002.png`, … |
|
||||
| `./results/` (trailing `/` or an existing directory) | `./results/0000.png` | `./results/0000.png`, `./results/0001.png`, … |
|
||||
| `my-cat.png` (file path) | `my-cat.png` (used verbatim) | `my-cat-0000.png`, `my-cat-0001.png`, … |
|
||||
|
||||
Directory outputs always use bare padded names (`0000`, `0001`, …). Explicit file paths preserve your chosen
|
||||
stem and get the padded index appended when the batch produces multiple outputs.
|
||||
|
||||
Use `--push-to` to upload outputs to a
|
||||
[Hugging Face storage bucket](https://huggingface.co/docs/hub/en/storage-buckets). Accepts an HF bucket
|
||||
id (`<namespace>/<name>`), an `hf://buckets/<namespace>/<name>[/<subpath>]`
|
||||
[HF URI](https://huggingface.co/docs/huggingface_hub/main/en/package_reference/hf_uris), or a browser URL
|
||||
for the same — a subpath is used as a folder prefix. The bucket is created if missing; objects land under
|
||||
`[<subpath>/]<run_id>/<filename>`.
|
||||
|
||||
```bash
|
||||
# HF bucket id — files at hf://buckets/alice/edit-outputs/<run_id>/<file>
|
||||
--push-to alice/edit-outputs
|
||||
|
||||
# URI with subpath — files at hf://buckets/alice/edit-outputs/greyscale/2026-07/<run_id>/<file>
|
||||
--push-to hf://buckets/alice/edit-outputs/greyscale/2026-07
|
||||
|
||||
# Browser URL copy-paste from the Hub also works.
|
||||
--push-to https://huggingface.co/buckets/alice/edit-outputs/tree/greyscale/2026-07
|
||||
```
|
||||
|
||||
The table below describes remote runs. For local runs, `--push-to` uploads the locally saved output. It does
|
||||
not suppress local file creation.
|
||||
|
||||
| `--push-to` set? | `--output` set? | Result |
|
||||
|---|---|---|
|
||||
| no | no | download to default local dir |
|
||||
| no | yes | download to `--output` |
|
||||
| yes | no | bucket only, no local download |
|
||||
| yes | yes | bucket AND `--output` |
|
||||
|
||||
`--format` shapes the stdout metadata (paths, timing, sandbox info) — it does not change the file format of
|
||||
the media itself. Written images are always PNG, videos MP4, audio WAV.
|
||||
|
||||
### Remote execution (`--remote`)
|
||||
|
||||
Run the same call inside a [Hugging Face Sandbox](https://huggingface.co/docs/huggingface_hub/en/guides/sandbox)
|
||||
— an isolated cloud VM the CLI drives over HTTP: it uploads inputs, installs deps, runs the pipeline, downloads
|
||||
outputs, then terminates the sandbox. Requires `huggingface_hub>=1.23`.
|
||||
|
||||
```bash
|
||||
diffusers-cli run \
|
||||
--model black-forest-labs/FLUX.1-dev --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "an astronaut riding a horse"}' \
|
||||
--remote --flavor a100-large
|
||||
```
|
||||
|
||||
Remote flags:
|
||||
|
||||
- `--flavor <name>` — sandbox hardware (e.g. `a10g-small`, `h200`, `rtx-pro-6000`).
|
||||
- `--timeout <duration>` — max wallclock for the run command inside the sandbox (default `10m`).
|
||||
- `--dependencies <pkg>` — extra pip deps (repeatable). Useful for pinning a diffusers branch tarball or
|
||||
adding pipeline-specific extras.
|
||||
- `--namespace <name>` — create the sandbox under a different HF org/account.
|
||||
- `--image <ref>` — override the sandbox image. Must ship torch + CUDA compatible with your `--flavor`'s
|
||||
driver.
|
||||
- `--volume <bucket-id>[:<mount-path>]` — mount an [HF storage bucket](https://huggingface.co/docs/hub/en/storage-buckets)
|
||||
into the sandbox as a read-write directory. Repeatable. Default mount path is
|
||||
`/mnt/buckets/<bucket-id>`. Reference mounted files from `--pipeline-kwargs` like any other local path.
|
||||
Applied only on new sandbox creation — ignored when reconnecting via `--sandbox-id`.
|
||||
|
||||
By default each `--remote` run is ephemeral (create → run → download → kill). To reuse a warm sandbox across
|
||||
runs — keeping deps, the model weight cache, and the `torch.compile` cache on its disk — keep it alive and
|
||||
reconnect:
|
||||
|
||||
- `--keep-alive` — don't terminate the sandbox after the run; its id is printed.
|
||||
- `--sandbox-id <id>` — reconnect to a kept-alive sandbox instead of creating a new one.
|
||||
- `--idle-timeout <duration>` — auto-shutdown after this much inactivity (default `10m`). Applied only on new sandbox creation — ignored when reconnecting via `--sandbox-id`.
|
||||
|
||||
```bash
|
||||
# First run keeps the sandbox alive and prints sandbox_id=<id>.
|
||||
diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "a cat"}' --remote --flavor a100-large --keep-alive
|
||||
|
||||
# Reconnect for the next run — the model is already cached, so only inference runs.
|
||||
diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \
|
||||
--pipeline-kwargs '{"prompt": "a dog"}' --remote --flavor a100-large --sandbox-id <id>
|
||||
|
||||
# Stop it when done (or let it timeout).
|
||||
hf sandbox kill <id>
|
||||
```
|
||||
|
||||
## `custom_blocks`
|
||||
|
||||
Package a local `ModularPipelineBlocks` subclass for upload to the Hub. Reads a Python file, AST-scans it for
|
||||
subclasses of `ModularPipelineBlocks`, instantiates the chosen one, and calls `save_pretrained` in the current
|
||||
working directory.
|
||||
|
||||
```bash
|
||||
# Package the first block found in ./block.py
|
||||
diffusers-cli custom_blocks
|
||||
|
||||
# Point at a different file / pick a specific class
|
||||
diffusers-cli custom_blocks --block_module_name my_block.py --block_class_name MyDenoiseBlock
|
||||
```
|
||||
|
||||
The block class must be instantiable with zero constructor args — hardcode defaults in `__init__` or read
|
||||
config from the pipeline `state` at call time.
|
||||
|
||||
## `fp16_safetensors`
|
||||
|
||||
Convert a checkpoint on the Hub to fp16 `.safetensors` and push the result. Useful for shrinking a repo's
|
||||
weight size for faster loading. See `diffusers-cli fp16_safetensors --help` for the exact args.
|
||||
|
||||
## `skills`
|
||||
|
||||
Install skills from the diffusers repo ([`.ai/skills/`](https://github.com/huggingface/diffusers/tree/main/.ai/skills)).
|
||||
|
||||
```bash
|
||||
# Install a single skill
|
||||
diffusers-cli skills add "<skill name>"
|
||||
|
||||
# Install every skill in the registry
|
||||
diffusers-cli skills add --all
|
||||
|
||||
# List available skills
|
||||
diffusers-cli skills list
|
||||
|
||||
# Preview a skill's SKILL.md without installing
|
||||
diffusers-cli skills preview diffusers-cli
|
||||
|
||||
# Refetch and reinstall every managed skill
|
||||
diffusers-cli skills update
|
||||
|
||||
# Install to the user-level directory instead of the current project
|
||||
diffusers-cli skills add diffusers-cli --global
|
||||
```
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Usage example:
|
||||
TODO
|
||||
"""`diffusers-cli custom_blocks` — package a local `ModularPipelineBlocks` subclass for the Hub.
|
||||
|
||||
Parses `block.py` (or `--block_module_name`), instantiates the chosen block, and calls `save_pretrained` in the current
|
||||
working directory.
|
||||
"""
|
||||
|
||||
import ast
|
||||
@@ -28,7 +29,6 @@ from . import BaseDiffusersCLICommand
|
||||
|
||||
|
||||
EXPECTED_PARENT_CLASSES = ["ModularPipelineBlocks"]
|
||||
CONFIG = "config.json"
|
||||
|
||||
|
||||
def conversion_command_factory(args: Namespace):
|
||||
@@ -38,7 +38,27 @@ def conversion_command_factory(args: Namespace):
|
||||
class CustomBlocksCommand(BaseDiffusersCLICommand):
|
||||
@staticmethod
|
||||
def register_subcommand(parser: ArgumentParser):
|
||||
conversion_parser = parser.add_parser("custom_blocks")
|
||||
from argparse import RawDescriptionHelpFormatter
|
||||
|
||||
epilog = (
|
||||
"Examples\n"
|
||||
" $ diffusers-cli custom_blocks\n"
|
||||
" $ diffusers-cli custom_blocks --block_module_name my_block.py\n"
|
||||
" $ diffusers-cli custom_blocks --block_module_name my_block.py --block_class_name MyDenoiseBlock\n"
|
||||
"\n"
|
||||
"Learn more\n"
|
||||
" Use `diffusers-cli <command> --help` for more information about a command.\n"
|
||||
" Read the documentation at https://huggingface.co/docs/diffusers\n"
|
||||
)
|
||||
|
||||
conversion_parser = parser.add_parser(
|
||||
"custom_blocks",
|
||||
help="Package a local ModularPipelineBlocks subclass for the Hub.",
|
||||
usage="\n diffusers-cli custom_blocks [options]",
|
||||
epilog=epilog,
|
||||
formatter_class=RawDescriptionHelpFormatter,
|
||||
)
|
||||
conversion_parser._optionals.title = "Options"
|
||||
conversion_parser.add_argument(
|
||||
"--block_module_name",
|
||||
type=str,
|
||||
@@ -85,11 +105,6 @@ class CustomBlocksCommand(BaseDiffusersCLICommand):
|
||||
spec.loader.exec_module(module)
|
||||
getattr(module, child_class)().save_pretrained(os.getcwd())
|
||||
|
||||
# or, we could create it manually.
|
||||
# automap = self._create_automap(parent_class=parent_class, child_class=child_class)
|
||||
# with open(CONFIG, "w") as f:
|
||||
# json.dump(automap, f)
|
||||
|
||||
def _choose_block(self, candidates, chosen=None):
|
||||
for cls, base in candidates:
|
||||
if cls == chosen:
|
||||
@@ -108,10 +123,8 @@ class CustomBlocksCommand(BaseDiffusersCLICommand):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
|
||||
# extract all base names for this class
|
||||
base_names = [bname for b in node.bases if (bname := self._get_base_name(b)) is not None]
|
||||
|
||||
# for each allowed base that appears in the class's bases, emit a tuple
|
||||
for allowed in EXPECTED_PARENT_CLASSES:
|
||||
if allowed in base_names:
|
||||
results.append((node.name, allowed))
|
||||
@@ -125,8 +138,3 @@ class CustomBlocksCommand(BaseDiffusersCLICommand):
|
||||
val = self._get_base_name(node.value)
|
||||
return f"{val}.{node.attr}" if val else node.attr
|
||||
return None
|
||||
|
||||
def _create_automap(self, parent_class, child_class):
|
||||
module = str(self.block_module_name).replace(".py", "").rsplit(".", 1)[-1]
|
||||
auto_map = {f"{parent_class}": f"{module}.{child_class}"}
|
||||
return {"auto_map": auto_map}
|
||||
|
||||
@@ -15,23 +15,47 @@
|
||||
|
||||
from argparse import ArgumentParser
|
||||
|
||||
from huggingface_hub.cli._output import OutputFormat, out
|
||||
|
||||
from .custom_blocks import CustomBlocksCommand
|
||||
from .env import EnvironmentCommand
|
||||
from .fp16_safetensors import FP16SafetensorsCommand
|
||||
from .run import RunCommand
|
||||
from .schema import SchemaCommand
|
||||
from .skills import SkillsCommand
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser("Diffusers CLI tool", usage="diffusers-cli <command> [<args>]")
|
||||
commands_parser = parser.add_subparsers(help="diffusers-cli command helpers")
|
||||
parser = ArgumentParser(
|
||||
prog="diffusers-cli",
|
||||
usage="\n diffusers-cli [--format <fmt>] <command> [options]",
|
||||
)
|
||||
parser._optionals.title = "Options"
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=[m.value for m in OutputFormat],
|
||||
default=OutputFormat.auto.value,
|
||||
help=(
|
||||
"Output format. 'auto' (default) picks 'agent' when an AI coding agent is detected "
|
||||
"(via CLAUDECODE/CURSOR_AI/AIDER_AI_CONTEXT/... env vars) and 'human' otherwise. "
|
||||
"Must appear before the subcommand."
|
||||
),
|
||||
)
|
||||
commands_parser = parser.add_subparsers(title="Commands", metavar="<command>")
|
||||
|
||||
# Register commands
|
||||
EnvironmentCommand.register_subcommand(commands_parser)
|
||||
FP16SafetensorsCommand.register_subcommand(commands_parser)
|
||||
CustomBlocksCommand.register_subcommand(commands_parser)
|
||||
RunCommand.register_subcommand(commands_parser)
|
||||
SchemaCommand.register_subcommand(commands_parser)
|
||||
SkillsCommand.register_subcommand(commands_parser)
|
||||
|
||||
# Let's go
|
||||
args = parser.parse_args()
|
||||
|
||||
out.set_mode(OutputFormat(args.format))
|
||||
|
||||
if not hasattr(args, "func"):
|
||||
parser.print_help()
|
||||
exit(1)
|
||||
|
||||
@@ -54,7 +54,12 @@ def info_command_factory(_):
|
||||
class EnvironmentCommand(BaseDiffusersCLICommand):
|
||||
@staticmethod
|
||||
def register_subcommand(parser: ArgumentParser) -> None:
|
||||
download_parser = parser.add_parser("env")
|
||||
download_parser = parser.add_parser(
|
||||
"env",
|
||||
help="Print versions of diffusers and its dependencies (for bug reports).",
|
||||
usage="\n diffusers-cli env",
|
||||
)
|
||||
download_parser._optionals.title = "Options"
|
||||
download_parser.set_defaults(func=info_command_factory)
|
||||
|
||||
def run(self) -> dict:
|
||||
|
||||
@@ -33,6 +33,13 @@ from . import BaseDiffusersCLICommand
|
||||
|
||||
|
||||
def conversion_command_factory(args: Namespace):
|
||||
warnings.warn(
|
||||
"`diffusers-cli fp16_safetensors` is deprecated and will be removed in a future version. "
|
||||
"Convert weights to fp16 safetensors directly with `safetensors.torch.save_file` or via "
|
||||
"`pipeline.save_pretrained(..., safe_serialization=True, variant='fp16')`.",
|
||||
FutureWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if args.use_auth_token:
|
||||
warnings.warn(
|
||||
"The `--use_auth_token` flag is deprecated and will be removed in a future version."
|
||||
@@ -44,7 +51,12 @@ def conversion_command_factory(args: Namespace):
|
||||
class FP16SafetensorsCommand(BaseDiffusersCLICommand):
|
||||
@staticmethod
|
||||
def register_subcommand(parser: ArgumentParser):
|
||||
conversion_parser = parser.add_parser("fp16_safetensors")
|
||||
conversion_parser = parser.add_parser(
|
||||
"fp16_safetensors",
|
||||
help="[DEPRECATED] Convert a Hub checkpoint's weights to fp16 safetensors and push back as a PR.",
|
||||
usage="\n diffusers-cli fp16_safetensors [options]",
|
||||
)
|
||||
conversion_parser._optionals.title = "Options"
|
||||
conversion_parser.add_argument(
|
||||
"--ckpt_id",
|
||||
type=str,
|
||||
|
||||
1227
src/diffusers/commands/run.py
Normal file
1227
src/diffusers/commands/run.py
Normal file
File diff suppressed because it is too large
Load Diff
287
src/diffusers/commands/schema.py
Normal file
287
src/diffusers/commands/schema.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# Copyright 2026 The HuggingFace Team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""`diffusers-cli schema` — print the input schema for any pipeline repo.
|
||||
|
||||
Tries `DiffusionPipeline.config_name` first (so standard repos get their `__call__` signature introspected); falls back
|
||||
to `ModularPipelineBlocks.from_pretrained` for modular repos. No weights are downloaded — only the small index file
|
||||
(and any custom block code if `--trust-remote-code` is set).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from typing import Any
|
||||
|
||||
from huggingface_hub.cli._output import OutputFormat, out
|
||||
|
||||
from ..utils import logging
|
||||
from . import BaseDiffusersCLICommand
|
||||
|
||||
|
||||
logger = logging.get_logger("diffusers-cli/schema")
|
||||
|
||||
|
||||
def _schema(args: Namespace) -> None:
|
||||
"""Print the pipeline's input schema.
|
||||
|
||||
Tries `DiffusionPipeline.config_name` (= `model_index.json`) first; if present, introspects the declared pipeline
|
||||
class's `__call__` signature. Otherwise falls back to `ModularPipelineBlocks.from_pretrained` and reads the
|
||||
block-declared `inputs`. No weights downloaded either way.
|
||||
"""
|
||||
import diffusers
|
||||
|
||||
try:
|
||||
index = diffusers.DiffusionPipeline.load_config(args.model, token=args.token, revision=args.revision)
|
||||
except OSError:
|
||||
index = None
|
||||
|
||||
if index is not None:
|
||||
class_name = index.get("_class_name")
|
||||
if class_name is None:
|
||||
raise SystemExit(
|
||||
f"{diffusers.DiffusionPipeline.config_name} for {args.model!r} has no `_class_name` field."
|
||||
)
|
||||
pipeline_cls = getattr(diffusers, class_name, None)
|
||||
if pipeline_cls is None:
|
||||
raise SystemExit(
|
||||
f"Pipeline class {class_name!r} declared in {diffusers.DiffusionPipeline.config_name} "
|
||||
"is not exported by the installed diffusers."
|
||||
)
|
||||
|
||||
sig = inspect.signature(pipeline_cls.__call__)
|
||||
descriptions = _parse_docstring_args(pipeline_cls.__call__.__doc__) if args.verbose else {}
|
||||
schema: list[dict[str, Any]] = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "self":
|
||||
continue
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
has_default = param.default is not inspect.Parameter.empty
|
||||
schema.append(
|
||||
{
|
||||
"name": name,
|
||||
"type_hint": str(param.annotation) if param.annotation is not inspect.Parameter.empty else None,
|
||||
"default": param.default if has_default else None,
|
||||
"required": not has_default,
|
||||
"description": descriptions.get(name, ""),
|
||||
}
|
||||
)
|
||||
else:
|
||||
kwargs: dict[str, Any] = {"trust_remote_code": args.trust_remote_code}
|
||||
if args.revision:
|
||||
kwargs["revision"] = args.revision
|
||||
if args.token:
|
||||
kwargs["token"] = args.token
|
||||
|
||||
# If the repo declares custom code + external dependencies, surface them upfront so
|
||||
# the user knows what to install before we hit an ImportError inside from_pretrained.
|
||||
_warn_custom_block_requirements(args)
|
||||
|
||||
try:
|
||||
blocks = diffusers.ModularPipelineBlocks.from_pretrained(args.model, **kwargs)
|
||||
except Exception as e:
|
||||
hint = "\nPass --trust-remote-code if it ships custom block code." if not args.trust_remote_code else ""
|
||||
raise SystemExit(
|
||||
f"Could not read schema for {args.model!r}: no {diffusers.DiffusionPipeline.config_name} and "
|
||||
f"loading as a modular pipeline failed with:\n {type(e).__name__}: {e}{hint}"
|
||||
) from e
|
||||
|
||||
class_name = type(blocks).__name__
|
||||
schema = [
|
||||
{
|
||||
"name": p.name,
|
||||
"type_hint": str(p.type_hint) if p.type_hint is not None else None,
|
||||
"default": p.default,
|
||||
"required": p.required,
|
||||
"description": p.description,
|
||||
}
|
||||
for p in blocks.inputs
|
||||
]
|
||||
|
||||
if out.mode == OutputFormat.json:
|
||||
out.dict({"task": "schema", "model": args.model, "pipeline_class": class_name, "inputs": schema})
|
||||
elif out.mode == OutputFormat.agent:
|
||||
out.table(schema, headers=["name", "required", "type_hint", "default", "description"])
|
||||
else:
|
||||
out.text(f"{class_name} ({args.model}) inputs:")
|
||||
for entry in schema:
|
||||
tag = "required" if entry["required"] else f"optional, default={entry['default']!r}"
|
||||
out.text(f" {entry['name']} ({tag})")
|
||||
if entry["type_hint"]:
|
||||
out.text(f" type: {entry['type_hint']}")
|
||||
if entry["description"]:
|
||||
out.text(f" desc: {entry['description']}")
|
||||
|
||||
|
||||
def _warn_custom_block_requirements(args: Namespace) -> None:
|
||||
"""Warn upfront when a modular block ships custom code with declared external dependencies.
|
||||
|
||||
Reads `modular_config.json` if present; if it has an `auto_map` (custom code) and a non-empty `requirements`
|
||||
list/dict, prints a heads-up. `from_pretrained` will otherwise fail with an `ImportError` deep in the loader stack
|
||||
when a listed dep is missing.
|
||||
"""
|
||||
import diffusers
|
||||
|
||||
try:
|
||||
config = diffusers.ModularPipelineBlocks.load_config(args.model, token=args.token, revision=args.revision)
|
||||
except Exception:
|
||||
return # no modular_config.json or unreachable — nothing to warn about
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
if not config.get("auto_map"):
|
||||
return
|
||||
requirements = config.get("requirements")
|
||||
if not requirements:
|
||||
return
|
||||
|
||||
# `requirements` may be a dict {name: version} or (older repos) a list of [name, version] pairs.
|
||||
if isinstance(requirements, dict):
|
||||
pairs = list(requirements.items())
|
||||
elif isinstance(requirements, list):
|
||||
pairs = [(item[0], item[1]) for item in requirements if isinstance(item, (list, tuple)) and len(item) >= 2]
|
||||
else:
|
||||
pairs = []
|
||||
if not pairs:
|
||||
return
|
||||
|
||||
formatted = ", ".join(f"{name}=={version}" for name, version in pairs)
|
||||
logger.warning(
|
||||
f"{args.model!r} ships custom block code with external dependencies: {formatted}. "
|
||||
"You will need to install these in order to determine the pipeline schema."
|
||||
)
|
||||
|
||||
|
||||
def _parse_docstring_args(docstring: str | None) -> dict[str, str]:
|
||||
"""Extract per-argument descriptions from a Google-style `Args:` block.
|
||||
|
||||
Returns a `{name: description}` mapping. Best-effort — unrecognised formats just yield an empty dict rather than
|
||||
raising.
|
||||
"""
|
||||
if not docstring:
|
||||
return {}
|
||||
|
||||
lines = docstring.expandtabs().splitlines()
|
||||
start = None
|
||||
section_indent = 0
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() in ("Args:", "Arguments:", "Parameters:"):
|
||||
start = i + 1
|
||||
section_indent = len(line) - len(line.lstrip())
|
||||
break
|
||||
if start is None:
|
||||
return {}
|
||||
|
||||
descriptions: dict[str, str] = {}
|
||||
current_name: str | None = None
|
||||
current_lines: list[str] = []
|
||||
arg_indent: int | None = None
|
||||
name_pattern = re.compile(r"^(\w+)\s*(?:\([^)]*\))?\s*:?\s*(.*)$")
|
||||
|
||||
def _flush() -> None:
|
||||
if current_name and current_lines:
|
||||
descriptions[current_name] = " ".join(s.strip() for s in current_lines).strip()
|
||||
|
||||
for line in lines[start:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
indent = len(line) - len(line.lstrip())
|
||||
# A new top-level section ends the Args block.
|
||||
if indent <= section_indent and line.strip().endswith(":"):
|
||||
break
|
||||
if arg_indent is None:
|
||||
arg_indent = indent
|
||||
if indent == arg_indent:
|
||||
_flush()
|
||||
current_lines = []
|
||||
match = name_pattern.match(line.strip())
|
||||
if match:
|
||||
current_name = match.group(1)
|
||||
tail = match.group(2).strip()
|
||||
if tail:
|
||||
current_lines.append(tail)
|
||||
else:
|
||||
current_name = None
|
||||
elif current_name is not None and indent > arg_indent:
|
||||
current_lines.append(line.strip())
|
||||
_flush()
|
||||
return descriptions
|
||||
|
||||
|
||||
class SchemaCommand(BaseDiffusersCLICommand):
|
||||
task = "schema"
|
||||
|
||||
@staticmethod
|
||||
def register_subcommand(subparsers: _SubParsersAction) -> None:
|
||||
from argparse import RawDescriptionHelpFormatter
|
||||
|
||||
epilog = (
|
||||
"Examples\n"
|
||||
" $ diffusers-cli schema -m stabilityai/stable-diffusion-xl-base-1.0\n"
|
||||
" $ diffusers-cli schema -m black-forest-labs/FLUX.1-dev --verbose\n"
|
||||
" $ diffusers-cli --format json schema -m stabilityai/stable-diffusion-xl-base-1.0\n"
|
||||
"\n"
|
||||
"Learn more\n"
|
||||
" Use `diffusers-cli <command> --help` for more information about a command.\n"
|
||||
" Read the documentation at https://huggingface.co/docs/diffusers\n"
|
||||
)
|
||||
|
||||
parser: ArgumentParser = subparsers.add_parser(
|
||||
"schema",
|
||||
help="Print the input schema for a diffusers pipeline repo. No weights downloaded.",
|
||||
usage="\n diffusers-cli schema [options]",
|
||||
epilog=epilog,
|
||||
formatter_class=RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser._optionals.title = "Options"
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
required=True,
|
||||
help="Model id on the Hugging Face Hub or local path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--revision",
|
||||
default=None,
|
||||
help="Model revision (branch, tag, or commit SHA).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=None,
|
||||
help="Hugging Face token for gated/private models.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trust-remote-code",
|
||||
action="store_true",
|
||||
help="Allow custom code from the Hub (required for modular pipelines that ship block code).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Also include per-argument descriptions from the pipeline's __call__ docstring. "
|
||||
"Modular pipelines always include block-declared descriptions; --verbose populates "
|
||||
"the equivalent field for standard pipelines by parsing the Google-style Args: block."
|
||||
),
|
||||
)
|
||||
parser.set_defaults(func=SchemaCommand)
|
||||
|
||||
def __init__(self, args: Namespace):
|
||||
self.args = args
|
||||
|
||||
def run(self) -> None:
|
||||
_schema(self.args)
|
||||
344
src/diffusers/commands/skills.py
Normal file
344
src/diffusers/commands/skills.py
Normal file
@@ -0,0 +1,344 @@
|
||||
# Copyright 2026 The HuggingFace Team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""`diffusers-cli skills` — install Agent Skills bundles.
|
||||
|
||||
Skill bundles live under `.ai/skills/<name>/` in the diffusers repo and follow the Agent Skills standard: a directory
|
||||
containing `SKILL.md` (plus optional resources). Installs to `.agents/skills/<name>/` which Claude, Codex, and Cursor
|
||||
all discover.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from huggingface_hub.cli._output import out
|
||||
|
||||
from ..utils import logging
|
||||
from ..utils.constants import DIFFUSERS_REQUEST_TIMEOUT
|
||||
from . import BaseDiffusersCLICommand
|
||||
|
||||
|
||||
logger = logging.get_logger("diffusers-cli/skills")
|
||||
|
||||
|
||||
_REGISTRY_BASE = "https://api.github.com/repos/huggingface/diffusers/contents/.ai/skills"
|
||||
_REGISTRY_REF = "main"
|
||||
|
||||
# Native skill-discovery paths per agent. Claude Code reads only `.claude/skills/`; Codex and
|
||||
# Cursor read `.agents/skills/` (Cursor also honors `.claude/skills/` via compat, but installing
|
||||
# to `.agents/skills/` is the portable choice for both).
|
||||
_CLAUDE_SKILLS_DIR = Path(".claude") / "skills"
|
||||
_AGENTS_SKILLS_DIR = Path(".agents") / "skills"
|
||||
|
||||
# Env vars set by each agent when it launches the CLI. Values are the install path to use.
|
||||
_AGENT_ENV_TO_DIR: dict[str, Path] = {
|
||||
"CLAUDECODE": _CLAUDE_SKILLS_DIR,
|
||||
"CLAUDE_CODE": _CLAUDE_SKILLS_DIR,
|
||||
"CODEX_SANDBOX": _AGENTS_SKILLS_DIR,
|
||||
"CURSOR_AI": _AGENTS_SKILLS_DIR,
|
||||
}
|
||||
# When no agent env var is set, install to every native path so whichever agent the user
|
||||
# later switches to picks the skill up.
|
||||
_ALL_INSTALL_DIRS: tuple[Path, ...] = (_CLAUDE_SKILLS_DIR, _AGENTS_SKILLS_DIR)
|
||||
|
||||
# Empty marker dropped inside each installed skill dir so `update` can distinguish our
|
||||
# installs from user-placed skills at the same paths.
|
||||
_MANAGED_MARKER_FILE = ".diffusers-skill-managed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _registry_url(name: str = "") -> str:
|
||||
"""API URL for the registry root, or for a single skill bundle when `name` is given."""
|
||||
path = f"/{name}" if name else ""
|
||||
return f"{_REGISTRY_BASE}{path}?ref={_REGISTRY_REF}"
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise SystemExit(f"Not found in registry: {url}") from e
|
||||
raise SystemExit(f"Registry fetch failed: HTTP {e.response.status_code} {e.response.reason_phrase}") from e
|
||||
except httpx.HTTPError as e:
|
||||
raise SystemExit(f"Could not reach registry: {e}") from e
|
||||
|
||||
|
||||
def _walk_skill_files(name: str) -> list[tuple[str, str]]:
|
||||
files: list[tuple[str, str]] = []
|
||||
|
||||
def _walk(api_url: str, prefix: str) -> None:
|
||||
for entry in _fetch_json(api_url):
|
||||
if entry["type"] == "file":
|
||||
files.append((f"{prefix}{entry['name']}", entry["download_url"]))
|
||||
elif entry["type"] == "dir":
|
||||
_walk(entry["url"], f"{prefix}{entry['name']}/")
|
||||
|
||||
_walk(_registry_url(name), "")
|
||||
return files
|
||||
|
||||
|
||||
def _download_skill_bundle(name: str) -> dict[str, bytes]:
|
||||
files = _walk_skill_files(name)
|
||||
if not files:
|
||||
raise SystemExit(f"Skill '{name}' has no files in the registry.")
|
||||
bundle: dict[str, bytes] = {}
|
||||
for rel_path, url in files:
|
||||
resp = httpx.get(url, timeout=DIFFUSERS_REQUEST_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
bundle[rel_path] = resp.content
|
||||
return bundle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install / discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _detect_install_dirs() -> tuple[Path, ...]:
|
||||
"""Pick where to install based on the launching agent.
|
||||
|
||||
If we detect a specific agent from its env var, install only there. If nothing is detected, install to every native
|
||||
path so any agent picks the skill up later.
|
||||
"""
|
||||
for env_var, skills_dir in _AGENT_ENV_TO_DIR.items():
|
||||
if os.environ.get(env_var):
|
||||
return (skills_dir,)
|
||||
return _ALL_INSTALL_DIRS
|
||||
|
||||
|
||||
def _install_skill(name: str, bundle: dict[str, bytes], root: Path, skills_dir: Path, force: bool) -> Path:
|
||||
skill_dir = root / skills_dir / name
|
||||
if skill_dir.exists():
|
||||
if not force:
|
||||
raise SystemExit(f"Skill already installed at {skill_dir}. Use --force to reinstall.")
|
||||
shutil.rmtree(skill_dir)
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
for rel_path, data in bundle.items():
|
||||
target = skill_dir / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
(skill_dir / _MANAGED_MARKER_FILE).touch()
|
||||
return skill_dir
|
||||
|
||||
|
||||
def _has_local_changes(skill_dir: Path, bundle: dict[str, bytes]) -> bool:
|
||||
"""True if the installed skill has any file that differs from `bundle` or has extra files.
|
||||
|
||||
The marker file is ignored. Compares raw bytes so a whitespace-only edit still counts as dirty.
|
||||
"""
|
||||
on_disk: dict[str, bytes] = {}
|
||||
for path in skill_dir.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = str(path.relative_to(skill_dir))
|
||||
if rel == _MANAGED_MARKER_FILE:
|
||||
continue
|
||||
on_disk[rel] = path.read_bytes()
|
||||
return on_disk != bundle
|
||||
|
||||
|
||||
def _discover_installed(root: Path) -> list[tuple[Path, str]]:
|
||||
"""Return `(skills_dir, name)` pairs for every managed install under `root`."""
|
||||
found: list[tuple[Path, str]] = []
|
||||
for skills_dir in _ALL_INSTALL_DIRS:
|
||||
skills_root = root / skills_dir
|
||||
if not skills_root.exists():
|
||||
continue
|
||||
for d in sorted(skills_root.iterdir()):
|
||||
if d.is_dir() and (d / _MANAGED_MARKER_FILE).exists():
|
||||
found.append((skills_dir, d.name))
|
||||
return found
|
||||
|
||||
|
||||
class SkillsCommand(BaseDiffusersCLICommand):
|
||||
@staticmethod
|
||||
def register_subcommand(subparsers: _SubParsersAction) -> None:
|
||||
parser: ArgumentParser = subparsers.add_parser(
|
||||
"skills",
|
||||
help="Manage Agent Skills for AI assistants.",
|
||||
usage="\n diffusers-cli skills <add|list|update|preview> [options]",
|
||||
)
|
||||
parser._optionals.title = "Options"
|
||||
actions = parser.add_subparsers(dest="skills_action", required=True, metavar="<action>")
|
||||
|
||||
add = actions.add_parser("add", help="Download and install a skill.")
|
||||
add.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Skill name (e.g. diffusers-cli, custom-blocks). Omit and pass --all to install every skill.",
|
||||
)
|
||||
add.add_argument(
|
||||
"--all",
|
||||
dest="install_all",
|
||||
action="store_true",
|
||||
help="Install every skill in the registry. Mutually exclusive with a positional name.",
|
||||
)
|
||||
add.add_argument(
|
||||
"--global",
|
||||
"-g",
|
||||
dest="install_global",
|
||||
action="store_true",
|
||||
help="Install globally (user-level) instead of in the current project directory.",
|
||||
)
|
||||
add.add_argument("--force", action="store_true", help="Overwrite existing skills in the destination.")
|
||||
add.set_defaults(func=SkillsCommand)
|
||||
|
||||
list_action = actions.add_parser("list", help="List available skills in the registry.")
|
||||
list_action.set_defaults(func=SkillsCommand)
|
||||
|
||||
update = actions.add_parser("update", help="Re-download and reinstall managed skills.")
|
||||
update.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Optional installed skill name to update. Omit to update every managed skill.",
|
||||
)
|
||||
update.add_argument(
|
||||
"--global",
|
||||
"-g",
|
||||
dest="install_global",
|
||||
action="store_true",
|
||||
help="Update skills installed globally (user-level) instead of the current project.",
|
||||
)
|
||||
update.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite skills even if they have local modifications since install.",
|
||||
)
|
||||
update.set_defaults(func=SkillsCommand)
|
||||
|
||||
preview = actions.add_parser("preview", help="Print a skill's SKILL.md from the registry.")
|
||||
preview.add_argument("name", help="Skill name to preview.")
|
||||
preview.set_defaults(func=SkillsCommand)
|
||||
|
||||
def __init__(self, args: Namespace):
|
||||
self.args = args
|
||||
|
||||
def run(self) -> None:
|
||||
if self.args.skills_action == "add":
|
||||
self._add()
|
||||
elif self.args.skills_action == "list":
|
||||
self._list()
|
||||
elif self.args.skills_action == "update":
|
||||
self._update()
|
||||
elif self.args.skills_action == "preview":
|
||||
self._preview()
|
||||
|
||||
def _add(self) -> None:
|
||||
if self.args.install_all and self.args.name:
|
||||
raise SystemExit("--all and a positional skill name are mutually exclusive.")
|
||||
if not self.args.install_all and not self.args.name:
|
||||
raise SystemExit("Pass a skill name (e.g. diffusers-cli) or --all to install every skill.")
|
||||
|
||||
root = Path.home() if self.args.install_global else Path.cwd()
|
||||
install_dirs = _detect_install_dirs()
|
||||
names = self._resolve_names()
|
||||
|
||||
installed: list[str] = []
|
||||
failed: list[str] = []
|
||||
for name in names:
|
||||
try:
|
||||
bundle = _download_skill_bundle(name)
|
||||
for skills_dir in install_dirs:
|
||||
_install_skill(name, bundle, root, skills_dir, self.args.force)
|
||||
installed.append(name)
|
||||
except (SystemExit, httpx.HTTPError) as e:
|
||||
# Downgrade to a warning so one broken skill doesn't abort the batch.
|
||||
logger.warning(f"Skipping skill {name!r}: {e}")
|
||||
failed.append(name)
|
||||
|
||||
if not installed:
|
||||
raise SystemExit(f"No skills installed. Failed: {failed}")
|
||||
out.result(
|
||||
f"Installed {len(installed)} skill(s)",
|
||||
installed=", ".join(installed),
|
||||
failed=", ".join(failed) if failed else None,
|
||||
paths=", ".join(str(root / d) for d in install_dirs),
|
||||
)
|
||||
|
||||
def _update(self) -> None:
|
||||
root = Path.home() if self.args.install_global else Path.cwd()
|
||||
installed = _discover_installed(root)
|
||||
if self.args.name is not None:
|
||||
installed = [entry for entry in installed if entry[1] == self.args.name]
|
||||
if not installed:
|
||||
raise SystemExit(f"No installed skill named {self.args.name!r} found under {root}.")
|
||||
if not installed:
|
||||
raise SystemExit(f"No managed skills found under {root}.")
|
||||
|
||||
# Group by skill name so we redownload each bundle once even if it's installed to
|
||||
# multiple locations (e.g. both .claude/skills/ and .agents/skills/).
|
||||
by_name: dict[str, list[Path]] = {}
|
||||
for skills_dir, name in installed:
|
||||
by_name.setdefault(name, []).append(skills_dir)
|
||||
|
||||
updated: list[str] = []
|
||||
failed: list[str] = []
|
||||
skipped: list[str] = []
|
||||
for name, dirs in sorted(by_name.items()):
|
||||
try:
|
||||
bundle = _download_skill_bundle(name)
|
||||
for skills_dir in dirs:
|
||||
skill_dir = root / skills_dir / name
|
||||
if not self.args.force and _has_local_changes(skill_dir, bundle):
|
||||
logger.warning(
|
||||
f"Skill {name!r} at {skill_dir} has local modifications; "
|
||||
"skipping. Pass --force to overwrite them."
|
||||
)
|
||||
skipped.append(name)
|
||||
continue
|
||||
_install_skill(name, bundle, root, skills_dir, force=True)
|
||||
updated.append(name)
|
||||
except (SystemExit, httpx.HTTPError) as e:
|
||||
logger.warning(f"Skipping skill {name!r}: {e}")
|
||||
failed.append(name)
|
||||
|
||||
out.result(
|
||||
f"Updated {len(updated)} skill(s)",
|
||||
updated=", ".join(updated),
|
||||
skipped=", ".join(skipped) if skipped else None,
|
||||
failed=", ".join(failed) if failed else None,
|
||||
)
|
||||
|
||||
def _preview(self) -> None:
|
||||
bundle = _download_skill_bundle(self.args.name)
|
||||
skill_md = bundle.get("SKILL.md")
|
||||
if skill_md is None:
|
||||
raise SystemExit(f"Skill {self.args.name!r} has no SKILL.md in the registry.")
|
||||
print(skill_md.decode())
|
||||
|
||||
def _list(self) -> None:
|
||||
entries = _fetch_json(_registry_url())
|
||||
skills = [{"name": e["name"]} for e in entries if e["type"] == "dir" and not e["name"].startswith(".")]
|
||||
if not skills:
|
||||
raise SystemExit("No skills found in registry.")
|
||||
out.table(skills, headers=["name"])
|
||||
|
||||
def _resolve_names(self) -> list[str]:
|
||||
if self.args.install_all:
|
||||
entries = _fetch_json(_registry_url())
|
||||
return sorted(e["name"] for e in entries if e["type"] == "dir" and not e["name"].startswith("."))
|
||||
return [self.args.name]
|
||||
306
tests/others/test_cli_commands.py
Normal file
306
tests/others/test_cli_commands.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# Copyright 2026 The HuggingFace Team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Essential unit tests for the non-``skills`` CLI commands.
|
||||
|
||||
One test per contract that would ship broken if regressed. Grouped by command.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from argparse import ArgumentParser, Namespace
|
||||
|
||||
import pytest
|
||||
|
||||
from diffusers.commands.custom_blocks import CustomBlocksCommand
|
||||
from diffusers.commands.run import (
|
||||
_build_task_kwargs,
|
||||
_kwargs_to_argv,
|
||||
_load_pipeline,
|
||||
_parse_pipeline_kwargs,
|
||||
_resolve_dtype,
|
||||
_resolve_media_inputs,
|
||||
_upload_inputs_to_sandbox,
|
||||
)
|
||||
from diffusers.commands.schema import _parse_docstring_args
|
||||
from diffusers.utils.testing_utils import require_accelerator, require_torch_gpu
|
||||
|
||||
|
||||
AVAILABLE_COMMANDS = ("env", "fp16_safetensors", "custom_blocks", "run", "schema", "skills")
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
def test_parse_pipeline_kwargs(self):
|
||||
assert _parse_pipeline_kwargs('{"prompt": "a cat", "steps": 50}') == {"prompt": "a cat", "steps": 50}
|
||||
with pytest.raises(SystemExit, match="must be valid JSON"):
|
||||
_parse_pipeline_kwargs('{"prompt": "unterminated')
|
||||
with pytest.raises(SystemExit, match="must decode to a JSON object"):
|
||||
_parse_pipeline_kwargs('["not", "an", "object"]')
|
||||
|
||||
def test_resolve_dtype_unknown_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown dtype"):
|
||||
_resolve_dtype("dummy-dtype")
|
||||
|
||||
def test_resolve_media_inputs(self, monkeypatch):
|
||||
monkeypatch.setattr("diffusers.commands.run.load_image", lambda v: f"img({v})")
|
||||
monkeypatch.setattr("diffusers.commands.run.load_video", lambda v: [f"frame({v})"])
|
||||
# Scalar strings load individually; string-lists load each entry (batched inputs);
|
||||
# `prompt` isn't a media key so it passes through; non-string / non-string-list values
|
||||
# (e.g. a pre-loaded PIL object modeled here as a dict) pass through untouched.
|
||||
preloaded = {"pre": "loaded"}
|
||||
kwargs = {
|
||||
"image": "url1",
|
||||
"control_video": "url2",
|
||||
"prompt": "text",
|
||||
"mask_image": ["a.png", "b.png"],
|
||||
"control_image": ["c.png"],
|
||||
"video": ["v1.mp4", "v2.mp4"],
|
||||
"ip_adapter_image": preloaded,
|
||||
}
|
||||
_resolve_media_inputs(kwargs)
|
||||
assert kwargs == {
|
||||
"image": "img(url1)",
|
||||
"control_video": ["frame(url2)"],
|
||||
"prompt": "text",
|
||||
"mask_image": ["img(a.png)", "img(b.png)"],
|
||||
"control_image": ["img(c.png)"],
|
||||
"video": [["frame(v1.mp4)"], ["frame(v2.mp4)"]],
|
||||
"ip_adapter_image": preloaded,
|
||||
}
|
||||
|
||||
def test_resolve_media_inputs_audio_batch(self, monkeypatch):
|
||||
# Batched audio: each entry loads, the paired sampling-rate kwarg defaults to the first entry's rate.
|
||||
monkeypatch.setattr("diffusers.commands.run._load_audio", lambda v: (f"wave({v})", 44100))
|
||||
kwargs = {"initial_audio_waveforms": ["a.wav", "b.wav"]}
|
||||
_resolve_media_inputs(kwargs)
|
||||
assert kwargs == {
|
||||
"initial_audio_waveforms": ["wave(a.wav)", "wave(b.wav)"],
|
||||
"initial_audio_sampling_rate": 44100,
|
||||
}
|
||||
|
||||
def test_upload_inputs_to_sandbox_batched(self, tmp_path):
|
||||
# Batched media on --remote: `_upload_inputs_to_sandbox` uploads each local path in a list
|
||||
# and rewrites the JSON in place. URLs and non-existent paths pass through untouched.
|
||||
import json as _json
|
||||
|
||||
local_a = tmp_path / "a.png"
|
||||
local_b = tmp_path / "b.png"
|
||||
local_a.write_bytes(b"a")
|
||||
local_b.write_bytes(b"b")
|
||||
|
||||
uploads: list[tuple[str, str]] = []
|
||||
|
||||
class FakeFiles:
|
||||
def upload(self, src, dst):
|
||||
uploads.append((src, dst))
|
||||
|
||||
class FakeSbx:
|
||||
files = FakeFiles()
|
||||
|
||||
args = Namespace(
|
||||
pipeline_kwargs=_json.dumps(
|
||||
{
|
||||
"image": str(local_a), # scalar local → uploaded
|
||||
"mask_image": [str(local_a), "https://example.com/x.png", str(local_b)], # mixed batch
|
||||
"prompt": "unchanged",
|
||||
}
|
||||
)
|
||||
)
|
||||
_upload_inputs_to_sandbox(args, FakeSbx(), run_id="rid")
|
||||
|
||||
assert len(uploads) == 3 # a (scalar), a (list[0]), b (list[2]); URL skipped
|
||||
rewritten = _json.loads(args.pipeline_kwargs)
|
||||
assert rewritten["image"].startswith("/tmp/diffusers-cli/inputs/rid/image_")
|
||||
assert rewritten["mask_image"][0].startswith("/tmp/diffusers-cli/inputs/rid/mask_image_0_")
|
||||
assert rewritten["mask_image"][1] == "https://example.com/x.png" # URL untouched
|
||||
assert rewritten["mask_image"][2].startswith("/tmp/diffusers-cli/inputs/rid/mask_image_2_")
|
||||
assert rewritten["prompt"] == "unchanged"
|
||||
|
||||
def test_kwargs_to_argv(self):
|
||||
argv = _kwargs_to_argv(
|
||||
"run", {"cpu_offload": "model", "vae_tiling": True, "dependencies": ["torch", "accelerate"]}
|
||||
)
|
||||
assert argv[0] == "run"
|
||||
assert "--cpu-offload" in argv and argv[argv.index("--cpu-offload") + 1] == "model"
|
||||
assert "--vae-tiling" in argv
|
||||
assert argv.count("--dependencies") == 2 and "torch" in argv and "accelerate" in argv
|
||||
|
||||
def test_remote_argv_omits_hf_job_args(self):
|
||||
# `_build_task_kwargs` must strip REMOTE_KEYS (flags that control how the remote run is
|
||||
# dispatched, not the sandbox CLI) plus None/False values before we forward argv to the sandbox.
|
||||
args = Namespace(
|
||||
remote=True,
|
||||
flavor="a100-large",
|
||||
timeout="10m",
|
||||
format="auto",
|
||||
image="pytorch/pytorch:2.10.0",
|
||||
func=object(),
|
||||
model="x",
|
||||
dtype="bf16",
|
||||
device_map="cuda", # not in REMOTE_KEYS: forwarded to the sandbox
|
||||
revision=None,
|
||||
trust_remote_code=False,
|
||||
vae_tiling=True,
|
||||
)
|
||||
assert _build_task_kwargs(args) == {
|
||||
"model": "x",
|
||||
"dtype": "bf16",
|
||||
"device_map": "cuda",
|
||||
"vae_tiling": True,
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Route flags through the CLI parser and call `_load_pipeline` directly to assert
|
||||
# their effect on a real tiny pipeline. `hf-internal-testing/tiny-flux-pipe` is small
|
||||
# enough to load without a GPU and is already used across the pipeline suite.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
pretrained_model_name_or_path = "hf-internal-testing/tiny-flux-pipe"
|
||||
|
||||
def _parse_run_argv(self, extra_argv: list[str]) -> Namespace:
|
||||
from diffusers.commands.run import RunCommand
|
||||
|
||||
parser = ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
RunCommand.register_subcommand(subparsers)
|
||||
return parser.parse_args(
|
||||
[
|
||||
"run",
|
||||
"--model",
|
||||
self.pretrained_model_name_or_path,
|
||||
"--pipeline-kwargs",
|
||||
'{"prompt": "a cat"}',
|
||||
*extra_argv,
|
||||
]
|
||||
)
|
||||
|
||||
@require_torch_gpu
|
||||
def test_group_offload_arg(self):
|
||||
from diffusers.hooks.group_offloading import _is_group_offload_enabled
|
||||
|
||||
args = self._parse_run_argv(["--cpu-offload", "group"])
|
||||
pipeline = _load_pipeline(args)
|
||||
assert _is_group_offload_enabled(pipeline.transformer)
|
||||
|
||||
@require_accelerator
|
||||
def test_model_cpu_offload_arg(self):
|
||||
import accelerate
|
||||
|
||||
args = self._parse_run_argv(["--cpu-offload", "model"])
|
||||
pipeline = _load_pipeline(args)
|
||||
assert isinstance(pipeline.transformer._hf_hook, accelerate.hooks.CpuOffload)
|
||||
|
||||
def test_vae_tiling_arg(self):
|
||||
args = self._parse_run_argv(["--vae-tiling"])
|
||||
pipeline = _load_pipeline(args)
|
||||
assert pipeline.vae.use_tiling is True
|
||||
|
||||
def test_vae_slicing_arg(self):
|
||||
args = self._parse_run_argv(["--vae-slicing"])
|
||||
pipeline = _load_pipeline(args)
|
||||
assert pipeline.vae.use_slicing is True
|
||||
|
||||
@require_torch_gpu
|
||||
def test_compile_arg(self):
|
||||
args = self._parse_run_argv(["--compile"])
|
||||
pipeline = _load_pipeline(args)
|
||||
# `_compile_denoiser` either applies `torch.compile` (which wraps the module in an
|
||||
# `OptimizedModule` with an `_orig_mod` attribute), or calls `compile_repeated_blocks`
|
||||
# which mutates the transformer's inner blocks in place. Either path leaves
|
||||
# `_orig_mod` on either the transformer itself or on one of its repeated blocks.
|
||||
has_torch_compile_wrapper = hasattr(pipeline.transformer, "_orig_mod") or any(
|
||||
hasattr(m, "_orig_mod") for m in pipeline.transformer.modules()
|
||||
)
|
||||
assert has_torch_compile_wrapper
|
||||
|
||||
@require_torch_gpu
|
||||
def test_attention_backend_arg(self):
|
||||
args = self._parse_run_argv(["--attention-backend", "flash_hub"])
|
||||
pipeline = _load_pipeline(args)
|
||||
# `set_attention_backend` stamps each attention processor's `_attention_backend` attr.
|
||||
backends = {
|
||||
m.processor._attention_backend
|
||||
for m in pipeline.transformer.modules()
|
||||
if hasattr(m, "processor") and hasattr(m.processor, "_attention_backend")
|
||||
}
|
||||
assert any(b.value == "flash_hub" for b in backends)
|
||||
|
||||
|
||||
class TestSchemaCommand:
|
||||
pretrained_model_name_or_path = "hf-internal-testing/tiny-flux-pipe"
|
||||
|
||||
def test_parse_docstring_args(self):
|
||||
docstring = """Description.
|
||||
|
||||
Args:
|
||||
prompt (str): The prompt text
|
||||
wraps across multiple lines
|
||||
for readability.
|
||||
steps (int, optional): Steps to run.
|
||||
"""
|
||||
result = _parse_docstring_args(docstring)
|
||||
assert result["steps"] == "Steps to run."
|
||||
assert "wraps across multiple lines" in result["prompt"]
|
||||
assert "\n" not in result["prompt"]
|
||||
|
||||
def test_schema(self, monkeypatch):
|
||||
# End-to-end: parse real argv → SchemaCommand.run → capture the emitted payload and
|
||||
# verify it contains the pipeline class + at least a `prompt` input parsed from the
|
||||
# pipeline's `__call__` signature.
|
||||
from huggingface_hub.cli._output import OutputFormat, out
|
||||
|
||||
from diffusers.commands.schema import SchemaCommand
|
||||
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(out, "dict", lambda payload: captured.update(payload))
|
||||
|
||||
parser = ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
SchemaCommand.register_subcommand(subparsers)
|
||||
args = parser.parse_args(["schema", "-m", self.pretrained_model_name_or_path])
|
||||
|
||||
out.set_mode(OutputFormat.json)
|
||||
args.func(args).run()
|
||||
|
||||
assert captured["pipeline_class"] == "FluxPipeline"
|
||||
assert captured["model"] == self.pretrained_model_name_or_path
|
||||
input_names = [p["name"] for p in captured["inputs"]]
|
||||
assert "prompt" in input_names
|
||||
|
||||
|
||||
class TestCustomBlocksCommand:
|
||||
def test_class_discovery(self, tmp_path):
|
||||
block_py = tmp_path / "block.py"
|
||||
block_py.write_text(
|
||||
"class OtherBase:\n pass\n"
|
||||
"class NotABlock(OtherBase):\n pass\n"
|
||||
"class MyBlock(ModularPipelineBlocks):\n pass\n"
|
||||
)
|
||||
cmd = CustomBlocksCommand()
|
||||
assert cmd._get_class_names(block_py) == [("MyBlock", "ModularPipelineBlocks")]
|
||||
|
||||
broken = tmp_path / "broken.py"
|
||||
broken.write_text("class Broken(:\n pass\n")
|
||||
with pytest.raises(ValueError, match="Could not parse"):
|
||||
cmd._get_class_names(broken)
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_toplevel_help_lists_all_commands(self):
|
||||
result = subprocess.run(
|
||||
["python", "-m", "diffusers.commands.diffusers_cli", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
for cmd in AVAILABLE_COMMANDS:
|
||||
assert cmd in result.stdout
|
||||
103
tests/others/test_cli_skills.py
Normal file
103
tests/others/test_cli_skills.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# Copyright 2026 The HuggingFace Team. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Unit tests for ``diffusers.commands.skills``.
|
||||
|
||||
Exercises the install / discovery / agent-detection logic. The GitHub Contents API path
|
||||
(``_fetch_json`` / ``_download_skill_bundle``) is left out — it needs the network.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from diffusers.commands.skills import (
|
||||
_AGENT_ENV_TO_DIR,
|
||||
_AGENTS_SKILLS_DIR,
|
||||
_ALL_INSTALL_DIRS,
|
||||
_CLAUDE_SKILLS_DIR,
|
||||
_MANAGED_MARKER_FILE,
|
||||
_detect_install_dirs,
|
||||
_discover_installed,
|
||||
_install_skill,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_BUNDLE = {
|
||||
"SKILL.md": b"---\nname: example-skill\ndescription: An example skill.\n---\n\n# Example\n",
|
||||
"extra.md": b"# Extra reference file\n",
|
||||
"reference/script.py": b"print('hello from a nested reference')\n",
|
||||
}
|
||||
|
||||
|
||||
class TestInstallSkill:
|
||||
def test_writes_bundle_and_marker(self, tmp_path):
|
||||
location = _install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False)
|
||||
skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / "example-skill"
|
||||
assert location == skill_dir
|
||||
assert (skill_dir / "SKILL.md").read_bytes() == SAMPLE_BUNDLE["SKILL.md"]
|
||||
assert (skill_dir / "extra.md").read_bytes() == SAMPLE_BUNDLE["extra.md"]
|
||||
assert (skill_dir / "reference" / "script.py").read_bytes() == SAMPLE_BUNDLE["reference/script.py"]
|
||||
assert (skill_dir / _MANAGED_MARKER_FILE).exists()
|
||||
|
||||
def test_errors_without_force_on_existing_dir(self, tmp_path):
|
||||
_install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False)
|
||||
with pytest.raises(SystemExit, match="Use --force to reinstall"):
|
||||
_install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False)
|
||||
|
||||
def test_force_replaces_existing(self, tmp_path):
|
||||
_install_skill("example-skill", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False)
|
||||
new_bundle = {"SKILL.md": b"different content"}
|
||||
_install_skill("example-skill", new_bundle, tmp_path, _CLAUDE_SKILLS_DIR, force=True)
|
||||
skill_dir = tmp_path / _CLAUDE_SKILLS_DIR / "example-skill"
|
||||
assert (skill_dir / "SKILL.md").read_bytes() == b"different content"
|
||||
assert not (skill_dir / "extra.md").exists()
|
||||
assert (skill_dir / _MANAGED_MARKER_FILE).exists()
|
||||
|
||||
|
||||
class TestDetectInstallDirs:
|
||||
def test_no_env_installs_everywhere(self, monkeypatch):
|
||||
for var in _AGENT_ENV_TO_DIR:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert _detect_install_dirs() == _ALL_INSTALL_DIRS
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env_var", "expected"),
|
||||
[
|
||||
("CLAUDECODE", _CLAUDE_SKILLS_DIR),
|
||||
("CLAUDE_CODE", _CLAUDE_SKILLS_DIR),
|
||||
("CODEX_SANDBOX", _AGENTS_SKILLS_DIR),
|
||||
("CURSOR_AI", _AGENTS_SKILLS_DIR),
|
||||
],
|
||||
)
|
||||
def test_env_var_picks_specific_target(self, monkeypatch, env_var, expected):
|
||||
for var in _AGENT_ENV_TO_DIR:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setenv(env_var, "1")
|
||||
assert _detect_install_dirs() == (expected,)
|
||||
|
||||
|
||||
class TestDiscoverInstalled:
|
||||
def test_returns_managed_installs_from_all_paths(self, tmp_path):
|
||||
_install_skill("skill-a", SAMPLE_BUNDLE, tmp_path, _CLAUDE_SKILLS_DIR, force=False)
|
||||
_install_skill("skill-b", SAMPLE_BUNDLE, tmp_path, _AGENTS_SKILLS_DIR, force=False)
|
||||
# User-placed skill without our marker — should be ignored.
|
||||
unmanaged = tmp_path / _CLAUDE_SKILLS_DIR / "user-placed"
|
||||
unmanaged.mkdir(parents=True)
|
||||
(unmanaged / "SKILL.md").write_bytes(b"user content")
|
||||
|
||||
assert _discover_installed(tmp_path) == [
|
||||
(_CLAUDE_SKILLS_DIR, "skill-a"),
|
||||
(_AGENTS_SKILLS_DIR, "skill-b"),
|
||||
]
|
||||
|
||||
def test_returns_empty_when_no_skills_dir(self, tmp_path):
|
||||
assert _discover_installed(tmp_path) == []
|
||||
Reference in New Issue
Block a user