Skip to content

Commit ddb24bc

Browse files
committed
[CLI] Add 'hf repo list' command with filtering and sorting
1 parent 4c87760 commit ddb24bc

File tree

3 files changed

+210
-0
lines changed

3 files changed

+210
-0
lines changed

docs/source/en/package_reference/cli.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,7 @@ $ hf repo [OPTIONS] COMMAND [ARGS]...
10031003
* `branch`: Manage branches for a repo on the Hub.
10041004
* `create`: Create a new repo on the Hub.
10051005
* `delete`: Delete a repo from the Hub.
1006+
* `list`: List repositories (models, datasets,...
10061007
* `move`: Move a repository from a namespace to...
10071008
* `settings`: Update the settings of a repository.
10081009
* `tag`: Manage tags for a repo on the Hub.
@@ -1115,6 +1116,27 @@ $ hf repo delete [OPTIONS] REPO_ID
11151116
* `--missing-ok / --no-missing-ok`: If set to True, do not raise an error if repo does not exist. [default: no-missing-ok]
11161117
* `--help`: Show this message and exit.
11171118

1119+
### `hf repo list`
1120+
1121+
List repositories (models, datasets, spaces) hosted on the Hub.
1122+
1123+
**Usage**:
1124+
1125+
```console
1126+
$ hf repo list [OPTIONS]
1127+
```
1128+
1129+
**Options**:
1130+
1131+
* `--repo-type [model|dataset|space]`: The type of repository (model, dataset, or space). [default: model]
1132+
* `--limit INTEGER`: Limit the number of results. [default: 10]
1133+
* `--filter TEXT`: Filter by tags (e.g. 'text-classification'). Can be used multiple times.
1134+
* `--search TEXT`: Search by name.
1135+
* `--author TEXT`: Filter by author or organization.
1136+
* `--sort TEXT`: Sort results key, optionally with direction (e.g. 'likes', 'downloads:asc').
1137+
* `--token TEXT`: A User Access Token generated from https://huggingface.co/settings/tokens.
1138+
* `--help`: Show this message and exit.
1139+
11181140
### `hf repo move`
11191141

11201142
Move a repository from a namespace to another namespace.

src/huggingface_hub/cli/repo.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"""
2323

2424
import enum
25+
import json
26+
import re
27+
from datetime import timezone
2528
from typing import Annotated, Optional
2629

2730
import typer
@@ -43,6 +46,8 @@
4346

4447
logger = logging.get_logger(__name__)
4548

49+
_SORT_PATTERN = re.compile(r"^(?P<key>[a-zA-Z0-9_-]+)(?::(?P<order>asc|desc))?$")
50+
4651
repo_cli = typer_factory(help="Manage repos on the Hub.")
4752
tag_cli = typer_factory(help="Manage tags for a repo on the Hub.")
4853
branch_cli = typer_factory(help="Manage branches for a repo on the Hub.")
@@ -160,6 +165,92 @@ def repo_settings(
160165
print(f"Successfully updated the settings of {ANSI.bold(repo_id)} on the Hub.")
161166

162167

168+
@repo_cli.command("list", help="List repositories (models, datasets, spaces) hosted on the Hub.")
169+
def repo_list(
170+
repo_type: RepoTypeOpt = RepoType.model,
171+
limit: Annotated[int, typer.Option(help="Limit the number of results.")] = 10,
172+
filter: Annotated[
173+
Optional[list[str]],
174+
typer.Option(help="Filter by tags (e.g. 'text-classification'). Can be used multiple times."),
175+
] = None,
176+
search: Annotated[Optional[str], typer.Option(help="Search by name.")] = None,
177+
author: Annotated[Optional[str], typer.Option(help="Filter by author or organization.")] = None,
178+
sort: Annotated[
179+
Optional[str],
180+
typer.Option(help="Sort results key, optionally with direction (e.g. 'likes', 'downloads:asc')."),
181+
] = None,
182+
token: TokenOpt = None,
183+
) -> None:
184+
api = get_hf_api(token=token)
185+
186+
sort_key = None
187+
direction = None
188+
189+
if sort:
190+
match = _SORT_PATTERN.match(sort)
191+
if not match:
192+
typer.echo(
193+
f"Error: Invalid sort format '{sort}'. Expected 'field' or 'field:direction' (e.g. 'downloads:desc')."
194+
)
195+
raise typer.Exit(1)
196+
197+
sort_key = match.group("key")
198+
order = match.group("order")
199+
200+
if order == "desc":
201+
direction = -1
202+
203+
output_data = []
204+
205+
try:
206+
if repo_type == RepoType.model:
207+
list_method = api.list_models
208+
elif repo_type == RepoType.dataset:
209+
list_method = api.list_datasets
210+
elif repo_type == RepoType.space:
211+
list_method = api.list_spaces
212+
213+
results = list_method(
214+
filter=filter,
215+
author=author,
216+
search=search,
217+
sort=sort_key,
218+
direction=direction,
219+
limit=limit,
220+
)
221+
222+
for repo in results:
223+
created_at_str = None
224+
if getattr(repo, "created_at", None):
225+
dt = repo.created_at
226+
if dt.tzinfo:
227+
dt = dt.astimezone(timezone.utc)
228+
created_at_str = dt.isoformat().replace("+00:00", "Z")
229+
230+
item = {
231+
"id": repo.id,
232+
"downloads": getattr(repo, "downloads", 0),
233+
"likes": getattr(repo, "likes", 0),
234+
"trendingScore": getattr(repo, "trending_score", None),
235+
"createdAt": created_at_str,
236+
"private": getattr(repo, "private", False),
237+
}
238+
239+
if hasattr(repo, "pipeline_tag") and repo.pipeline_tag:
240+
item["pipeline_tag"] = repo.pipeline_tag
241+
242+
if hasattr(repo, "library_name") and repo.library_name:
243+
item["library_name"] = repo.library_name
244+
245+
output_data.append(item)
246+
247+
except Exception as e:
248+
typer.echo(f"Error fetching {repo_type.value}s: {e}")
249+
raise typer.Exit(1)
250+
251+
typer.echo(json.dumps(output_data, indent=2))
252+
253+
163254
@branch_cli.command("create", help="Create a new branch for a repo on the Hub.")
164255
def branch_create(
165256
repo_id: RepoIdArg,

tests/test_cli.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,103 @@ def test_repo_delete_with_all_options(self, runner: CliRunner) -> None:
12651265
)
12661266

12671267

1268+
class TestRepoListCommand:
1269+
def test_repo_list_basic(self, runner: CliRunner) -> None:
1270+
"""Test basic listing of models with defaults and JSON output verification."""
1271+
from datetime import datetime, timezone
1272+
1273+
# Mock a repo object (simulating ModelInfo)
1274+
repo = Mock()
1275+
repo.id = "user/model-id"
1276+
repo.downloads = 100
1277+
repo.likes = 50
1278+
repo.trending_score = 10
1279+
repo.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
1280+
repo.private = False
1281+
repo.pipeline_tag = "text-classification"
1282+
repo.library_name = "transformers"
1283+
1284+
with patch("huggingface_hub.cli.repo.get_hf_api") as api_cls:
1285+
api = api_cls.return_value
1286+
api.list_models.return_value = iter([repo])
1287+
1288+
result = runner.invoke(app, ["repo", "list"])
1289+
1290+
assert result.exit_code == 0
1291+
api_cls.assert_called_once_with(token=None)
1292+
1293+
# Verify API was called with correct default arguments
1294+
api.list_models.assert_called_once()
1295+
_, kwargs = api.list_models.call_args
1296+
assert kwargs["limit"] == 10
1297+
assert kwargs["sort"] is None
1298+
1299+
# Verify JSON output
1300+
output = json.loads(result.stdout)
1301+
assert len(output) == 1
1302+
assert output[0]["id"] == "user/model-id"
1303+
assert output[0]["createdAt"] == "2024-01-01T12:00:00Z"
1304+
assert output[0]["pipeline_tag"] == "text-classification"
1305+
1306+
def test_repo_list_sorting_parsing(self, runner: CliRunner) -> None:
1307+
"""Test that 'downloads:asc' is correctly parsed into sort='downloads', direction=1."""
1308+
with patch("huggingface_hub.cli.repo.get_hf_api") as api_cls:
1309+
api = api_cls.return_value
1310+
api.list_models.return_value = iter([])
1311+
1312+
# Test Ascending
1313+
result = runner.invoke(app, ["repo", "list", "--sort", "downloads:asc"])
1314+
assert result.exit_code == 0
1315+
_, kwargs = api.list_models.call_args
1316+
assert kwargs["sort"] == "downloads"
1317+
assert kwargs["direction"] == 1
1318+
1319+
# Test Descending
1320+
result = runner.invoke(app, ["repo", "list", "--sort", "likes:desc"])
1321+
assert result.exit_code == 0
1322+
_, kwargs = api.list_models.call_args
1323+
assert kwargs["sort"] == "likes"
1324+
assert kwargs["direction"] == -1
1325+
1326+
def test_repo_list_datasets_with_filter(self, runner: CliRunner) -> None:
1327+
"""Test listing datasets with multiple filters."""
1328+
with patch("huggingface_hub.cli.repo.get_hf_api") as api_cls:
1329+
api = api_cls.return_value
1330+
api.list_datasets.return_value = iter([])
1331+
1332+
result = runner.invoke(
1333+
app, ["repo", "list", "--repo-type", "dataset", "--filter", "text-classification", "--filter", "en"]
1334+
)
1335+
1336+
assert result.exit_code == 0
1337+
api.list_datasets.assert_called_once()
1338+
_, kwargs = api.list_datasets.call_args
1339+
# Typer passes multiple --filter flags as a list
1340+
assert kwargs["filter"] == ["text-classification", "en"]
1341+
1342+
def test_repo_list_invalid_sort(self, runner: CliRunner) -> None:
1343+
"""Test that invalid sort string raises a user-friendly error."""
1344+
result = runner.invoke(app, ["repo", "list", "--sort", "bad:format:here"])
1345+
assert result.exit_code == 1
1346+
assert "Error: Invalid sort format" in result.stdout
1347+
1348+
def test_repo_list_api_error_handling(self, runner: CliRunner) -> None:
1349+
"""Test graceful handling of API errors (like BadRequest during iteration)."""
1350+
with patch("huggingface_hub.cli.repo.get_hf_api") as api_cls:
1351+
api = api_cls.return_value
1352+
1353+
def error_generator(**kwargs):
1354+
raise Exception("Invalid sort direction")
1355+
yield
1356+
1357+
api.list_models.side_effect = error_generator
1358+
1359+
result = runner.invoke(app, ["repo", "list"])
1360+
1361+
assert result.exit_code == 1
1362+
assert "Error fetching models" in result.stdout
1363+
1364+
12681365
class TestInferenceEndpointsCommands:
12691366
def test_list(self, runner: CliRunner) -> None:
12701367
endpoint = Mock(raw={"name": "demo"})

0 commit comments

Comments
 (0)