Skip to content

Commit 2782465

Browse files
authored
feat(hub): list collections (#1568)
Issue: #271 This PR only includes the call of list collections. Why? - It's one of my early contributions to the project, so I think I should split the [PR](#367) of @hackpk into many small PRs. - > I was really hoping to be able to make a collection of our best models and datasets, so that they can be displayed on our landing page. - I think list collections solve the problem of displaying collections in real time I’m having a bit of trouble with the type, and I need to search through the project to understand it better. We don't have any public schema API? Thanks for review it.
1 parent abcad62 commit 2782465

File tree

5 files changed

+638
-0
lines changed

5 files changed

+638
-0
lines changed

packages/hub/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export * from "./list-datasets";
1818
export * from "./list-files";
1919
export * from "./list-models";
2020
export * from "./list-spaces";
21+
export * from "./list-collections";
2122
export * from "./model-info";
2223
export * from "./oauth-handle-redirect";
2324
export * from "./oauth-login-url";
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, it } from "vitest";
2+
import { listCollections } from "./list-collections";
3+
import type { ApiCollectionInfo } from "../types/api/api-collection";
4+
import { TEST_HUB_URL } from "../test/consts";
5+
6+
describe("listCollections", () => {
7+
it("should list collections", async () => {
8+
const results: ApiCollectionInfo[] = [];
9+
10+
for await (const entry of listCollections({
11+
search: { owner: ["quanghuynt14"] },
12+
hubUrl: TEST_HUB_URL,
13+
})) {
14+
if (entry.slug !== "quanghuynt14/test-collection-6866ff686ca2d2e0a1931507") {
15+
continue;
16+
}
17+
18+
if (typeof entry.lastUpdated === "string") {
19+
entry.lastUpdated = "2025-07-03T22:18:56.239Z";
20+
}
21+
22+
if (entry.items && Array.isArray(entry.items)) {
23+
entry.items.map((item) => {
24+
if ("lastModified" in item && typeof item.lastModified === "string") {
25+
item.lastModified = "2025-07-01T00:36:29.000Z";
26+
}
27+
if ("lastUpdated" in item && typeof item.lastUpdated === "string") {
28+
item.lastUpdated = "2025-07-01T00:41:27.525Z";
29+
}
30+
});
31+
}
32+
33+
results.push(entry);
34+
}
35+
36+
const collection = results[0];
37+
const items = collection.items;
38+
collection.items = [];
39+
40+
// Check all properties of the collection except items
41+
expect(collection).deep.equal({
42+
slug: "quanghuynt14/test-collection-6866ff686ca2d2e0a1931507",
43+
title: "Test Collection",
44+
description: "This collection is only for test",
45+
gating: false,
46+
lastUpdated: "2025-07-03T22:18:56.239Z",
47+
owner: {
48+
_id: "6866ff3936a7677f427f99e3",
49+
avatarUrl: "/avatars/b51088e22fb7194888551365b1bafada.svg",
50+
fullname: "Quang-Huy Tran",
51+
name: "quanghuynt14",
52+
type: "user",
53+
isPro: false,
54+
isHf: false,
55+
isHfAdmin: false,
56+
isMod: false,
57+
},
58+
items: [],
59+
theme: "purple",
60+
private: false,
61+
upvotes: 0,
62+
isUpvotedByUser: false,
63+
});
64+
65+
// Check for item type model
66+
expect(items[0]).deep.equal({
67+
_id: "686700086ca2d2e0a193150b",
68+
position: 0,
69+
type: "model",
70+
author: "quanghuynt14",
71+
authorData: {
72+
_id: "6866ff3936a7677f427f99e3",
73+
avatarUrl: "/avatars/b51088e22fb7194888551365b1bafada.svg",
74+
fullname: "Quang-Huy Tran",
75+
name: "quanghuynt14",
76+
type: "user",
77+
isPro: false,
78+
isHf: false,
79+
isHfAdmin: false,
80+
isMod: false,
81+
},
82+
downloads: 0,
83+
gated: false,
84+
id: "quanghuynt14/TestModel",
85+
availableInferenceProviders: [],
86+
lastModified: "2025-07-01T00:36:29.000Z",
87+
likes: 0,
88+
private: false,
89+
repoType: "model",
90+
isLikedByUser: false,
91+
});
92+
93+
// Check for item type dataset
94+
expect(items[1]).deep.equal({
95+
_id: "686701cd86ea6972ba6c9da5",
96+
position: 1,
97+
type: "dataset",
98+
author: "quanghuynt14",
99+
downloads: 0,
100+
gated: false,
101+
id: "quanghuynt14/TestDataset",
102+
lastModified: "2025-07-01T00:36:29.000Z",
103+
private: false,
104+
repoType: "dataset",
105+
likes: 0,
106+
isLikedByUser: false,
107+
});
108+
109+
// Check for item type space
110+
expect(items[2]).deep.equal({
111+
_id: "6867000f6ca2d2e0a193150e",
112+
position: 2,
113+
type: "space",
114+
author: "quanghuynt14",
115+
authorData: {
116+
_id: "6866ff3936a7677f427f99e3",
117+
avatarUrl: "/avatars/b51088e22fb7194888551365b1bafada.svg",
118+
fullname: "Quang-Huy Tran",
119+
name: "quanghuynt14",
120+
type: "user",
121+
isPro: false,
122+
isHf: false,
123+
isHfAdmin: false,
124+
isMod: false,
125+
},
126+
colorFrom: "pink",
127+
colorTo: "indigo",
128+
createdAt: "2025-07-03T22:10:39.000Z",
129+
emoji: "🏆",
130+
id: "quanghuynt14/TestSpace",
131+
lastModified: "2025-07-01T00:36:29.000Z",
132+
likes: 0,
133+
pinned: false,
134+
private: false,
135+
sdk: "docker",
136+
repoType: "space",
137+
runtime: {
138+
stage: "BUILDING",
139+
hardware: {
140+
current: null,
141+
requested: "cpu-basic",
142+
},
143+
storage: null,
144+
gcTimeout: 172800,
145+
replicas: {
146+
current: 0,
147+
requested: 1,
148+
},
149+
},
150+
shortDescription: "This space is only for test",
151+
title: "TestSpace",
152+
isLikedByUser: false,
153+
trendingScore: 0,
154+
tags: ["docker", "region:us"],
155+
});
156+
157+
// Check for item type collection
158+
expect(items[3]).deep.equal({
159+
_id: "68670014f25517a0a7eaf505",
160+
position: 3,
161+
type: "collection",
162+
id: "6866ff686ca2d2e0a1931507",
163+
slug: "quanghuynt14/test-collection-6866ff686ca2d2e0a1931507",
164+
title: "Test Collection",
165+
description: "This collection is only for test",
166+
lastUpdated: "2025-07-01T00:41:27.525Z",
167+
numberItems: 5,
168+
owner: {
169+
_id: "6866ff3936a7677f427f99e3",
170+
avatarUrl: "/avatars/b51088e22fb7194888551365b1bafada.svg",
171+
fullname: "Quang-Huy Tran",
172+
name: "quanghuynt14",
173+
type: "user",
174+
isPro: false,
175+
isHf: false,
176+
isHfAdmin: false,
177+
isMod: false,
178+
},
179+
theme: "purple",
180+
shareUrl: "https://hub-ci.huggingface.co/collections/quanghuynt14/test-collection-6866ff686ca2d2e0a1931507",
181+
upvotes: 0,
182+
isUpvotedByUser: false,
183+
});
184+
});
185+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { HUB_URL } from "../consts";
2+
import { createApiError } from "../error";
3+
import type { CredentialsParams } from "../types/public";
4+
import { checkCredentials } from "../utils/checkCredentials";
5+
import { parseLinkHeader } from "../utils/parseLinkHeader";
6+
import type { ApiCollectionInfo } from "../types/api/api-collection";
7+
8+
export async function* listCollections(
9+
params?: {
10+
search?: {
11+
/**
12+
* Filter collections created by specific owners (users or organizations).
13+
*/
14+
owner?: string[];
15+
/**
16+
* Filter collections containing specific items.
17+
* Value must be the item_type and item_id concatenated.
18+
* Example: "models/teknium/OpenHermes-2.5-Mistral-7B", "datasets/rajpurkar/squad" or "papers/2311.12983".
19+
*/
20+
item?: string[];
21+
/**
22+
* Filter based on substrings for titles & descriptions.
23+
*/
24+
q?: string;
25+
};
26+
/**
27+
* Sort the returned collections. Supported values are "lastModified", "trending" (default) and "upvotes".
28+
*/
29+
sort?: "lastModified" | "trending" | "upvotes";
30+
/**
31+
* Set to limit the number of collections returned.
32+
*/
33+
limit?: number;
34+
hubUrl?: string;
35+
/**
36+
* Custom fetch function to use instead of the default one, for example to use a proxy or edit headers.
37+
*/
38+
fetch?: typeof fetch;
39+
} & Partial<CredentialsParams>
40+
): AsyncGenerator<ApiCollectionInfo> {
41+
const accessToken = params && checkCredentials(params);
42+
43+
const searchParams = new URLSearchParams();
44+
45+
let totalToFetch = params?.limit ?? Infinity;
46+
searchParams.append("limit", String(Math.min(totalToFetch, 100)));
47+
48+
if (params?.sort) {
49+
searchParams.append("sort", params.sort);
50+
}
51+
52+
if (params?.search?.owner) {
53+
for (const owner of params.search.owner) {
54+
searchParams.append("owner", owner);
55+
}
56+
}
57+
58+
if (params?.search?.item) {
59+
for (const item of params.search.item) {
60+
searchParams.append("item", item);
61+
}
62+
}
63+
64+
if (params?.search?.q) {
65+
searchParams.append("q", params.search.q);
66+
}
67+
68+
let url: string | undefined = `${params?.hubUrl || HUB_URL}/api/collections?${searchParams}`;
69+
70+
while (url) {
71+
const res: Response = await (params?.fetch ?? fetch)(url, {
72+
headers: {
73+
accept: "application/json",
74+
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined),
75+
},
76+
});
77+
78+
if (!res.ok) {
79+
throw await createApiError(res);
80+
}
81+
82+
const collections: ApiCollectionInfo[] = await res.json();
83+
84+
for (const collection of collections) {
85+
yield collection;
86+
87+
totalToFetch--;
88+
89+
if (totalToFetch <= 0) {
90+
return;
91+
}
92+
}
93+
94+
const linkHeader = res.headers.get("Link");
95+
96+
url = linkHeader ? parseLinkHeader(linkHeader).next : undefined;
97+
}
98+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type ApiAuthor =
2+
| {
3+
avatarUrl: string;
4+
fullname: string;
5+
name: string;
6+
isHf: boolean;
7+
isHfAdmin: boolean;
8+
isMod: boolean;
9+
followerCount?: number;
10+
type: "org";
11+
isEnterprise: boolean;
12+
isUserFollowing?: boolean;
13+
}
14+
| {
15+
avatarUrl: string;
16+
fullname: string;
17+
name: string;
18+
isHf: boolean;
19+
isHfAdmin: boolean;
20+
isMod: boolean;
21+
followerCount?: number;
22+
type: "user";
23+
isPro: boolean;
24+
_id: string;
25+
isUserFollowing?: boolean;
26+
};

0 commit comments

Comments
 (0)