durable_huggingface_hub 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +29 -0
  3. data/.rubocop.yml +108 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +547 -0
  6. data/Rakefile +106 -0
  7. data/devenv.lock +171 -0
  8. data/devenv.nix +15 -0
  9. data/devenv.yaml +8 -0
  10. data/huggingface_hub.gemspec +63 -0
  11. data/lib/durable_huggingface_hub/authentication.rb +245 -0
  12. data/lib/durable_huggingface_hub/cache.rb +508 -0
  13. data/lib/durable_huggingface_hub/configuration.rb +191 -0
  14. data/lib/durable_huggingface_hub/constants.rb +145 -0
  15. data/lib/durable_huggingface_hub/errors.rb +412 -0
  16. data/lib/durable_huggingface_hub/file_download.rb +831 -0
  17. data/lib/durable_huggingface_hub/hf_api.rb +1278 -0
  18. data/lib/durable_huggingface_hub/repo_card.rb +430 -0
  19. data/lib/durable_huggingface_hub/types/cache_info.rb +298 -0
  20. data/lib/durable_huggingface_hub/types/commit_info.rb +149 -0
  21. data/lib/durable_huggingface_hub/types/dataset_info.rb +158 -0
  22. data/lib/durable_huggingface_hub/types/model_info.rb +154 -0
  23. data/lib/durable_huggingface_hub/types/space_info.rb +158 -0
  24. data/lib/durable_huggingface_hub/types/user.rb +179 -0
  25. data/lib/durable_huggingface_hub/types.rb +205 -0
  26. data/lib/durable_huggingface_hub/utils/auth.rb +174 -0
  27. data/lib/durable_huggingface_hub/utils/headers.rb +220 -0
  28. data/lib/durable_huggingface_hub/utils/http.rb +329 -0
  29. data/lib/durable_huggingface_hub/utils/paths.rb +230 -0
  30. data/lib/durable_huggingface_hub/utils/progress.rb +217 -0
  31. data/lib/durable_huggingface_hub/utils/retry.rb +165 -0
  32. data/lib/durable_huggingface_hub/utils/validators.rb +236 -0
  33. data/lib/durable_huggingface_hub/version.rb +8 -0
  34. data/lib/huggingface_hub.rb +205 -0
  35. metadata +334 -0
@@ -0,0 +1,1278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require_relative "constants"
6
+ require_relative "configuration"
7
+ require_relative "utils/http"
8
+ require_relative "utils/auth"
9
+ require_relative "utils/validators"
10
+ require_relative "types"
11
+
12
+ module DurableHuggingfaceHub
13
+ # Main API client for interacting with the HuggingFace Hub
14
+ #
15
+ # This class provides methods for accessing and managing repositories,
16
+ # models, datasets, and spaces on the HuggingFace Hub. It handles
17
+ # authentication, request management, and response parsing.
18
+ #
19
+ # @example Initialize with default configuration
20
+ # api = DurableHuggingfaceHub::HfApi.new
21
+ #
22
+ # @example Initialize with custom token and endpoint
23
+ # api = DurableHuggingfaceHub::HfApi.new(
24
+ # token: "hf_...",
25
+ # endpoint: "https://huggingface.co"
26
+ # )
27
+ #
28
+ # @example Get model information
29
+ # model = api.model_info("bert-base-uncased")
30
+ # puts model.id
31
+ # puts model.downloads
32
+ #
33
+ # @example List models with filtering
34
+ # models = api.list_models(filter: "text-classification", limit: 10)
35
+ # models.each { |m| puts m.id }
36
+ class HfApi
37
+ # @return [String, nil] Authentication token for API requests
38
+ attr_reader :token
39
+
40
+ # @return [String] Base endpoint URL for the HuggingFace Hub
41
+ attr_reader :endpoint
42
+
43
+ # @return [DurableHuggingfaceHub::Utils::HttpClient] HTTP client instance
44
+ attr_reader :http_client
45
+
46
+ # Initialize a new HfApi client
47
+ #
48
+ # @param token [String, nil] HuggingFace authentication token.
49
+ # If nil, will attempt to retrieve from environment or token file.
50
+ # @param endpoint [String, nil] Base URL for the HuggingFace Hub API.
51
+ # Defaults to {DurableHuggingfaceHub::Constants::ENDPOINT}.
52
+ #
53
+ # @example Create client with auto-detected token
54
+ # api = DurableHuggingfaceHub::HfApi.new
55
+ #
56
+ # @example Create client with explicit token
57
+ # api = DurableHuggingfaceHub::HfApi.new(token: "hf_...")
58
+ #
59
+ # @example Create client with custom endpoint
60
+ # api = DurableHuggingfaceHub::HfApi.new(
61
+ # endpoint: "https://custom-hub.example.com"
62
+ # )
63
+ def initialize(token: nil, endpoint: nil)
64
+ @token = token || DurableHuggingfaceHub::Utils::Auth.get_token
65
+ @endpoint = endpoint || DurableHuggingfaceHub.configuration.endpoint
66
+ @http_client = DurableHuggingfaceHub::Utils::HttpClient.new(
67
+ endpoint: @endpoint,
68
+ token: @token
69
+ )
70
+ end
71
+
72
+ # Get comprehensive information about a repository
73
+ #
74
+ # This is a generic method that works for any repository type (model,
75
+ # dataset, or space). For type-specific methods, see {#model_info},
76
+ # {#dataset_info}, or {#space_info}.
77
+ #
78
+ # @param repo_id [String] Repository identifier in the format "namespace/name"
79
+ # or just "name" for repositories in your namespace
80
+ # @param repo_type [String, Symbol] Type of repository: "model", "dataset", or "space"
81
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA).
82
+ # Defaults to "main"
83
+ # @param timeout [Numeric, nil] Request timeout in seconds
84
+ #
85
+ # @return [DurableHuggingfaceHub::Types::ModelInfo, DurableHuggingfaceHub::Types::DatasetInfo, DurableHuggingfaceHub::Types::SpaceInfo]
86
+ # Repository information object, type depends on repo_type
87
+ #
88
+ # @raise [ArgumentError] If repo_id or repo_type is invalid
89
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If repository doesn't exist
90
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If revision doesn't exist
91
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors
92
+ #
93
+ # @example Get model information
94
+ # info = api.repo_info("bert-base-uncased", repo_type: "model")
95
+ # puts info.id
96
+ # puts info.downloads
97
+ #
98
+ # @example Get dataset information with specific revision
99
+ # info = api.repo_info("squad", repo_type: "dataset", revision: "v1.0")
100
+ # puts info.id
101
+ #
102
+ # @example Get space information
103
+ # info = api.repo_info("stabilityai/stable-diffusion", repo_type: "space")
104
+ # puts info.id
105
+ def repo_info(repo_id, repo_type: "model", revision: nil, timeout: nil)
106
+ # Validate inputs
107
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
108
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
109
+
110
+ if revision
111
+ DurableHuggingfaceHub::Utils::Validators.validate_revision(revision)
112
+ end
113
+
114
+ # Build API path
115
+ path = case repo_type.to_s
116
+ when "model"
117
+ "/api/models/#{repo_id}"
118
+ when "dataset"
119
+ "/api/datasets/#{repo_id}"
120
+ when "space"
121
+ "/api/spaces/#{repo_id}"
122
+ else
123
+ raise ArgumentError, "Invalid repo_type: #{repo_type}"
124
+ end
125
+
126
+ # Add revision if specified
127
+ params = {}
128
+ params[:revision] = revision if revision
129
+
130
+ # Make request
131
+ response = http_client.get(path, params: params, timeout: timeout)
132
+
133
+ # Parse response based on repo_type
134
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
135
+ case repo_type.to_s
136
+ when "model"
137
+ DurableHuggingfaceHub::Types::ModelInfo.from_hash(body)
138
+ when "dataset"
139
+ DurableHuggingfaceHub::Types::DatasetInfo.from_hash(body)
140
+ when "space"
141
+ DurableHuggingfaceHub::Types::SpaceInfo.from_hash(body)
142
+ end
143
+ end
144
+
145
+ # Get information about a specific model
146
+ #
147
+ # This is a convenience method that calls {#repo_info} with
148
+ # repo_type: "model".
149
+ #
150
+ # @param repo_id [String] Model repository identifier
151
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA).
152
+ # Defaults to "main"
153
+ # @param timeout [Numeric, nil] Request timeout in seconds
154
+ #
155
+ # @return [DurableHuggingfaceHub::Types::ModelInfo] Model information
156
+ #
157
+ # @raise [ArgumentError] If repo_id is invalid
158
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If model doesn't exist
159
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If revision doesn't exist
160
+ #
161
+ # @example Get basic model info
162
+ # model = api.model_info("bert-base-uncased")
163
+ # puts "Downloads: #{model.downloads}"
164
+ # puts "Likes: #{model.likes}"
165
+ #
166
+ # @example Get model info for specific revision
167
+ # model = api.model_info("gpt2", revision: "main")
168
+ # puts "SHA: #{model.sha}"
169
+ def model_info(repo_id, revision: nil, timeout: nil)
170
+ repo_info(repo_id, repo_type: "model", revision: revision, timeout: timeout)
171
+ end
172
+
173
+ # Get information about a specific dataset
174
+ #
175
+ # This is a convenience method that calls {#repo_info} with
176
+ # repo_type: "dataset".
177
+ #
178
+ # @param repo_id [String] Dataset repository identifier
179
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA).
180
+ # Defaults to "main"
181
+ # @param timeout [Numeric, nil] Request timeout in seconds
182
+ #
183
+ # @return [DurableHuggingfaceHub::Types::DatasetInfo] Dataset information
184
+ #
185
+ # @raise [ArgumentError] If repo_id is invalid
186
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If dataset doesn't exist
187
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If revision doesn't exist
188
+ #
189
+ # @example Get dataset info
190
+ # dataset = api.dataset_info("squad")
191
+ # puts "Downloads: #{dataset.downloads}"
192
+ # puts "Tags: #{dataset.tags}"
193
+ def dataset_info(repo_id, revision: nil, timeout: nil)
194
+ repo_info(repo_id, repo_type: "dataset", revision: revision, timeout: timeout)
195
+ end
196
+
197
+ # Get information about a specific space
198
+ #
199
+ # This is a convenience method that calls {#repo_info} with
200
+ # repo_type: "space".
201
+ #
202
+ # @param repo_id [String] Space repository identifier
203
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA).
204
+ # Defaults to "main"
205
+ # @param timeout [Numeric, nil] Request timeout in seconds
206
+ #
207
+ # @return [DurableHuggingfaceHub::Types::SpaceInfo] Space information
208
+ #
209
+ # @raise [ArgumentError] If repo_id is invalid
210
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If space doesn't exist
211
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If revision doesn't exist
212
+ #
213
+ # @example Get space info
214
+ # space = api.space_info("stabilityai/stable-diffusion")
215
+ # puts "Runtime: #{space.runtime}"
216
+ # puts "SDK: #{space.sdk}"
217
+ def space_info(repo_id, revision: nil, timeout: nil)
218
+ repo_info(repo_id, repo_type: "space", revision: revision, timeout: timeout)
219
+ end
220
+
221
+ # List models from the HuggingFace Hub with optional filtering
222
+ #
223
+ # Returns a list of models matching the specified criteria. Results can be
224
+ # filtered by tags, author, search query, and sorted by various metrics.
225
+ #
226
+ # @param filter [String, Hash, nil] Filter criteria:
227
+ # - String: Search query or single tag
228
+ # - Hash: Structured filters (e.g., {author: "google", task: "text-classification"})
229
+ # @param author [String, nil] Filter by author/organization
230
+ # @param search [String, nil] Search query for model names and descriptions
231
+ # @param sort [String, Symbol, nil] Sort criterion:
232
+ # "downloads", "likes", "updated", "created", "trending"
233
+ # @param direction [Integer, nil] Sort direction: -1 for descending, 1 for ascending
234
+ # @param limit [Integer, nil] Maximum number of results to return
235
+ # @param full [Boolean] If true, fetch full model information (slower)
236
+ # @param timeout [Numeric, nil] Request timeout in seconds
237
+ #
238
+ # @return [Array<DurableHuggingfaceHub::Types::ModelInfo>] List of models
239
+ #
240
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For API errors
241
+ #
242
+ # @example List all models
243
+ # models = api.list_models
244
+ #
245
+ # @example List models by author
246
+ # models = api.list_models(author: "google")
247
+ #
248
+ # @example Search for specific models
249
+ # models = api.list_models(search: "bert")
250
+ #
251
+ # @example Filter by task and sort by downloads
252
+ # models = api.list_models(
253
+ # filter: {task: "text-classification"},
254
+ # sort: "downloads",
255
+ # direction: -1,
256
+ # limit: 10
257
+ # )
258
+ #
259
+ # @example Filter by multiple criteria
260
+ # models = api.list_models(
261
+ # filter: {
262
+ # author: "facebook",
263
+ # library: "pytorch",
264
+ # language: "en"
265
+ # },
266
+ # limit: 20
267
+ # )
268
+ def list_models(filter: nil, author: nil, search: nil, sort: nil,
269
+ direction: nil, limit: nil, full: false, timeout: nil)
270
+ path = "/api/models"
271
+ params = build_list_params(
272
+ filter: filter,
273
+ author: author,
274
+ search: search,
275
+ sort: sort,
276
+ direction: direction,
277
+ limit: limit,
278
+ full: full
279
+ )
280
+
281
+ response = http_client.get(path, params: params, timeout: timeout)
282
+
283
+ # Response is an array of model objects
284
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
285
+ body.map { |model_data| DurableHuggingfaceHub::Types::ModelInfo.from_hash(model_data) }
286
+ end
287
+
288
+ # List datasets from the HuggingFace Hub with optional filtering
289
+ #
290
+ # Returns a list of datasets matching the specified criteria.
291
+ #
292
+ # @param filter [String, Hash, nil] Filter criteria
293
+ # @param author [String, nil] Filter by author/organization
294
+ # @param search [String, nil] Search query
295
+ # @param sort [String, Symbol, nil] Sort criterion
296
+ # @param direction [Integer, nil] Sort direction: -1 for descending, 1 for ascending
297
+ # @param limit [Integer, nil] Maximum number of results
298
+ # @param full [Boolean] Fetch full dataset information
299
+ # @param timeout [Numeric, nil] Request timeout in seconds
300
+ #
301
+ # @return [Array<DurableHuggingfaceHub::Types::DatasetInfo>] List of datasets
302
+ #
303
+ # @example List popular datasets
304
+ # datasets = api.list_datasets(sort: "downloads", limit: 10)
305
+ def list_datasets(filter: nil, author: nil, search: nil, sort: nil,
306
+ direction: nil, limit: nil, full: false, timeout: nil)
307
+ path = "/api/datasets"
308
+ params = build_list_params(
309
+ filter: filter,
310
+ author: author,
311
+ search: search,
312
+ sort: sort,
313
+ direction: direction,
314
+ limit: limit,
315
+ full: full
316
+ )
317
+
318
+ response = http_client.get(path, params: params, timeout: timeout)
319
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
320
+ body.map { |dataset_data| DurableHuggingfaceHub::Types::DatasetInfo.from_hash(dataset_data) }
321
+ end
322
+
323
+ # List spaces from the HuggingFace Hub with optional filtering
324
+ #
325
+ # Returns a list of spaces matching the specified criteria.
326
+ #
327
+ # @param filter [String, Hash, nil] Filter criteria
328
+ # @param author [String, nil] Filter by author/organization
329
+ # @param search [String, nil] Search query
330
+ # @param sort [String, Symbol, nil] Sort criterion
331
+ # @param direction [Integer, nil] Sort direction: -1 for descending, 1 for ascending
332
+ # @param limit [Integer, nil] Maximum number of results
333
+ # @param full [Boolean] Fetch full space information
334
+ # @param timeout [Numeric, nil] Request timeout in seconds
335
+ #
336
+ # @return [Array<DurableHuggingfaceHub::Types::SpaceInfo>] List of spaces
337
+ #
338
+ # @example List trending spaces
339
+ # spaces = api.list_spaces(sort: "trending", limit: 10)
340
+ def list_spaces(filter: nil, author: nil, search: nil, sort: nil,
341
+ direction: nil, limit: nil, full: false, timeout: nil)
342
+ path = "/api/spaces"
343
+ params = build_list_params(
344
+ filter: filter,
345
+ author: author,
346
+ search: search,
347
+ sort: sort,
348
+ direction: direction,
349
+ limit: limit,
350
+ full: full
351
+ )
352
+
353
+ response = http_client.get(path, params: params, timeout: timeout)
354
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
355
+ body.map { |space_data| DurableHuggingfaceHub::Types::SpaceInfo.from_hash(space_data) }
356
+ end
357
+
358
+ # Check if a repository exists on the HuggingFace Hub
359
+ #
360
+ # @param repo_id [String] Repository identifier
361
+ # @param repo_type [String, Symbol] Type of repository: "model", "dataset", or "space"
362
+ # @param timeout [Numeric, nil] Request timeout in seconds
363
+ #
364
+ # @return [Boolean] True if repository exists, false otherwise
365
+ #
366
+ # @example Check if model exists
367
+ # if api.repo_exists("bert-base-uncased")
368
+ # puts "Model exists!"
369
+ # end
370
+ def repo_exists(repo_id, repo_type: "model", timeout: nil)
371
+ repo_info(repo_id, repo_type: repo_type, timeout: timeout)
372
+ true
373
+ rescue DurableHuggingfaceHub::RepositoryNotFoundError
374
+ false
375
+ end
376
+
377
+ # Get current user information (requires authentication)
378
+ #
379
+ # Returns information about the authenticated user. Requires a valid
380
+ # authentication token.
381
+ #
382
+ # @param timeout [Numeric, nil] Request timeout in seconds
383
+ #
384
+ # @return [DurableHuggingfaceHub::Types::User] User information
385
+ #
386
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] If not authenticated or token is invalid
387
+ #
388
+ # @example Get current user info
389
+ # user = api.whoami
390
+ # puts "Logged in as: #{user.name}"
391
+ # puts "Type: #{user.type}"
392
+ def whoami(timeout: nil)
393
+ path = "/api/whoami-v2"
394
+ response = http_client.get(path, timeout: timeout)
395
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
396
+ DurableHuggingfaceHub::Types::User.from_hash(body)
397
+ end
398
+
399
+ # List files in a repository.
400
+ #
401
+ # @param repo_id [String] Repository ID
402
+ # @param repo_type [String, Symbol] Type of repository ("model", "dataset", or "space")
403
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA). Defaults to "main"
404
+ # @param timeout [Numeric, nil] Request timeout in seconds
405
+ # @return [Array<String>] List of file paths in the repository
406
+ # @raise [RepositoryNotFoundError] If repository doesn't exist
407
+ # @raise [RevisionNotFoundError] If revision doesn't exist
408
+ def list_repo_files(repo_id:, repo_type: "model", revision: nil, timeout: nil)
409
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
410
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
411
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
412
+
413
+ path = "/api/#{repo_type}s/#{repo_id}/tree"
414
+ params = { recursive: true }
415
+ params[:revision] = revision if revision
416
+
417
+ response = http_client.get(path, params: params, timeout: timeout)
418
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
419
+ body.map { |file_data| file_data["path"] }
420
+ end
421
+
422
+ # List repository contents in a hierarchical tree structure.
423
+ #
424
+ # This method provides a tree-like view of the repository contents,
425
+ # organized by directories and files with their metadata.
426
+ #
427
+ # @param repo_id [String] Repository ID
428
+ # @param repo_type [String, Symbol] Type of repository ("model", "dataset", or "space")
429
+ # @param revision [String, nil] Git revision (branch, tag, or commit SHA). Defaults to "main"
430
+ # @param path [String, nil] Path within repository to list (for subdirectories)
431
+ # @param recursive [Boolean] Whether to recursively list subdirectories. Defaults to false
432
+ # @param timeout [Numeric, nil] Request timeout in seconds
433
+ # @return [Hash] Tree structure with directories and files
434
+ # @raise [RepositoryNotFoundError] If repository doesn't exist
435
+ # @raise [RevisionNotFoundError] If revision doesn't exist
436
+ #
437
+ # @example Get repository tree
438
+ # tree = api.list_repo_tree(repo_id: "bert-base-uncased")
439
+ # puts tree.keys # ["config.json", "pytorch_model.bin", "tokenizer.json", ...]
440
+ #
441
+ # @example Get tree for a subdirectory
442
+ # subtree = api.list_repo_tree(
443
+ # repo_id: "my-model",
444
+ # path: "checkpoints"
445
+ # )
446
+ def list_repo_tree(repo_id:, repo_type: "model", revision: nil, path: nil, recursive: false, timeout: nil)
447
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
448
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
449
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
450
+ revision ||= "main"
451
+
452
+ # Build the API path
453
+ api_path = "/api/#{repo_type}s/#{repo_id}/tree"
454
+ api_path += "/#{path}" if path
455
+
456
+ params = { recursive: recursive }
457
+ params[:revision] = revision if revision
458
+
459
+ response = http_client.get(api_path, params: params, timeout: timeout)
460
+
461
+ # Organize the response into a tree structure
462
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
463
+ build_tree_structure(body)
464
+ end
465
+
466
+ # Get metadata about a file in a repository without downloading it.
467
+ #
468
+ # This method retrieves file metadata including size, ETag, and other information
469
+ # from the HuggingFace Hub API without downloading the actual file content.
470
+ #
471
+ # @param repo_id [String] The ID of the repository.
472
+ # @param filename [String] The path to the file within the repository.
473
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
474
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA). Defaults to "main".
475
+ # @param timeout [Numeric, nil] Request timeout in seconds.
476
+ # @return [Hash] File metadata including :size, :etag, :commit_hash, :last_modified, etc.
477
+ # @raise [ArgumentError] If repo_id, filename, or repo_type is invalid.
478
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
479
+ # @raise [DurableHuggingfaceHub::EntryNotFoundError] If the file does not exist.
480
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the revision does not exist.
481
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
482
+ #
483
+ # @example Get metadata for a model file
484
+ # metadata = api.get_hf_file_metadata(
485
+ # repo_id: "bert-base-uncased",
486
+ # filename: "config.json"
487
+ # )
488
+ # puts "Size: #{metadata[:size]} bytes"
489
+ # puts "ETag: #{metadata[:etag]}"
490
+ def get_hf_file_metadata(repo_id:, filename:, repo_type: "model", revision: nil, timeout: nil)
491
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
492
+ DurableHuggingfaceHub::Utils::Validators.validate_filename(filename)
493
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
494
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
495
+ revision ||= "main"
496
+
497
+ path = "/api/#{repo_type}s/#{repo_id}/resolve/#{revision}/#{filename}"
498
+
499
+ begin
500
+ response = http_client.head(path, timeout: timeout)
501
+
502
+ # Extract metadata from response headers
503
+ headers = response.headers
504
+ {
505
+ size: headers["x-linked-size"]&.to_i,
506
+ etag: DurableHuggingfaceHub::FileDownload.extract_etag(headers["etag"] || headers["x-linked-etag"]),
507
+ commit_hash: headers["x-repo-commit"],
508
+ last_modified: headers["last-modified"] ? Time.parse(headers["last-modified"]) : nil,
509
+ content_type: headers["content-type"],
510
+ filename: filename,
511
+ repo_id: repo_id,
512
+ repo_type: repo_type,
513
+ revision: revision
514
+ }.compact
515
+ rescue DurableHuggingfaceHub::EntryNotFoundError
516
+ raise
517
+ rescue DurableHuggingfaceHub::HfHubHTTPError => e
518
+ # Convert 404 to EntryNotFoundError for consistency
519
+ if e.status_code == 404
520
+ raise DurableHuggingfaceHub::EntryNotFoundError.new(
521
+ "File #{filename} not found in #{repo_id}@#{revision}"
522
+ )
523
+ end
524
+ raise
525
+ end
526
+ end
527
+
528
+ # Get metadata for multiple paths in a repository.
529
+ #
530
+ # This method efficiently retrieves metadata for multiple files or paths
531
+ # in a repository using batch requests where possible.
532
+ #
533
+ # @param repo_id [String] The ID of the repository.
534
+ # @param paths [Array<String>] Array of file paths within the repository.
535
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
536
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA). Defaults to "main".
537
+ # @param timeout [Numeric, nil] Request timeout in seconds.
538
+ # @return [Array<Hash>] Array of metadata hashes, one for each path. Missing files return nil.
539
+ # @raise [ArgumentError] If repo_id, paths, or repo_type is invalid.
540
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
541
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the revision does not exist.
542
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
543
+ #
544
+ # @example Get metadata for multiple files
545
+ # paths = ["config.json", "pytorch_model.bin", "tokenizer.json"]
546
+ # metadata_list = api.get_paths_info(
547
+ # repo_id: "bert-base-uncased",
548
+ # paths: paths
549
+ # )
550
+ # metadata_list.each_with_index do |metadata, i|
551
+ # if metadata
552
+ # puts "#{paths[i]}: #{metadata[:size]} bytes"
553
+ # else
554
+ # puts "#{paths[i]}: not found"
555
+ # end
556
+ # end
557
+ def get_paths_info(repo_id:, paths:, repo_type: "model", revision: nil, timeout: nil)
558
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
559
+ raise ArgumentError, "paths must be an array" unless paths.is_a?(Array)
560
+ paths.each { |path| DurableHuggingfaceHub::Utils::Validators.validate_filename(path) }
561
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
562
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
563
+ revision ||= "main"
564
+
565
+ # For now, implement sequentially. In the future, this could be optimized
566
+ # with concurrent requests or batch API calls if available.
567
+ paths.map do |path|
568
+ begin
569
+ get_hf_file_metadata(
570
+ repo_id: repo_id,
571
+ filename: path,
572
+ repo_type: repo_type,
573
+ revision: revision,
574
+ timeout: timeout
575
+ )
576
+ rescue DurableHuggingfaceHub::EntryNotFoundError
577
+ nil # Return nil for missing files
578
+ end
579
+ end
580
+ end
581
+
582
+ # Build a tree structure from the API response.
583
+ #
584
+ # @param items [Array<Hash>] Raw API response items
585
+ # @return [Hash] Organized tree structure
586
+ def build_tree_structure(items)
587
+ tree = {}
588
+
589
+ items.each do |item|
590
+ path = item["path"]
591
+ path_parts = path.split("/")
592
+
593
+ # Navigate/create nested structure
594
+ current = tree
595
+ path_parts.each_with_index do |part, index|
596
+ is_last = index == path_parts.length - 1
597
+
598
+ if is_last
599
+ # This is a file
600
+ current[part] = {
601
+ type: "file",
602
+ size: item["size"],
603
+ oid: item["oid"], # SHA for Git LFS files
604
+ lfs: item["lfs"] # LFS information if applicable
605
+ }.compact
606
+ else
607
+ # This is a directory
608
+ current[part] ||= { type: "directory", children: {} }
609
+ current = current[part][:children]
610
+ end
611
+ end
612
+ end
613
+
614
+ tree
615
+ end
616
+
617
+ # Create a new repository on the HuggingFace Hub.
618
+ #
619
+ # @param repo_id [String] The ID of the repository to create (e.g., "my-username/my-repo").
620
+ # @param repo_type [String, Symbol] The type of the repository ("model", "dataset", or "space"). Defaults to "model".
621
+ # @param private [Boolean] Whether the repository should be private. Defaults to false.
622
+ # @param organization [String, nil] The organization namespace to create the repository under.
623
+ # If nil, the repository is created under the authenticated user's namespace.
624
+ # @param timeout [Numeric, nil] Request timeout in seconds.
625
+ # @return [String] The URL of the newly created repository.
626
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
627
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For API errors (e.g., repository already exists, authentication error).
628
+ def create_repo(repo_id:, repo_type: "model", private: false, organization: nil, timeout: nil)
629
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
630
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
631
+
632
+ path = "/api/#{repo_type}s"
633
+ payload = {
634
+ name: repo_id.split("/").last,
635
+ private: private,
636
+ organization: organization
637
+ }
638
+
639
+ response = http_client.post(path, body: payload, timeout: timeout)
640
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
641
+ body["url"]
642
+ end
643
+
644
+ # Delete a repository from the HuggingFace Hub.
645
+ #
646
+ # @param repo_id [String] The ID of the repository to delete (e.g., "my-username/my-repo").
647
+ # @param repo_type [String, Symbol] The type of the repository ("model", "dataset", or "space"). Defaults to "model".
648
+ # @param token [String, nil] HuggingFace API token. If nil, will attempt to retrieve from environment or token file.
649
+ # @param timeout [Numeric, nil] Request timeout in seconds.
650
+ # @return [Boolean] True if the repository was successfully deleted.
651
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
652
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
653
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors (e.g., authentication error).
654
+ def delete_repo(repo_id:, repo_type: "model", token: nil, timeout: nil)
655
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
656
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
657
+
658
+ path = "/api/#{repo_type}s/#{repo_id}"
659
+ http_client.delete(path)
660
+ true
661
+ end
662
+
663
+ # Update the visibility of a repository (public/private).
664
+ #
665
+ # @param repo_id [String] The ID of the repository to update.
666
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
667
+ # @param private [Boolean] The new visibility status (true for private, false for public).
668
+ # @param timeout [Numeric, nil] Request timeout in seconds.
669
+ # @return [Boolean] True if the visibility was successfully updated.
670
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
671
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
672
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
673
+ def update_repo_visibility(repo_id:, repo_type: "model", private:, timeout: nil)
674
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
675
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
676
+
677
+ path = "/api/#{repo_type}s/#{repo_id}/settings"
678
+ payload = { private: private }
679
+
680
+ http_client.post(path, body: payload, timeout: timeout)
681
+ true
682
+ end
683
+
684
+ # Update various settings for a repository.
685
+ #
686
+ # @param repo_id [String] The ID of the repository to update.
687
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
688
+ # @param git_lfs_enabled [Boolean, nil] Whether Git LFS should be enabled for the repository.
689
+ # @param protected [Boolean, nil] Whether the repository should be protected.
690
+ # @param unlisted [Boolean, nil] Whether the repository should be unlisted.
691
+ # @param tags [Array<String>, nil] A list of tags to apply to the repository.
692
+ # @param default_branch [String, nil] The new default branch for the repository.
693
+ # @param timeout [Numeric, nil] Request timeout in seconds.
694
+ # @return [Boolean] True if the settings were successfully updated.
695
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
696
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
697
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
698
+ def update_repo_settings(
699
+ repo_id:,
700
+ repo_type: "model",
701
+ git_lfs_enabled: nil,
702
+ protected: nil,
703
+ unlisted: nil,
704
+ tags: nil,
705
+ default_branch: nil,
706
+ timeout: nil
707
+ )
708
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
709
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
710
+
711
+ path = "/api/#{repo_type}s/#{repo_id}/settings"
712
+ payload = {}
713
+ payload[:git_lfs_enabled] = git_lfs_enabled unless git_lfs_enabled.nil?
714
+ payload[:protected] = protected unless protected.nil?
715
+ payload[:unlisted] = unlisted unless unlisted.nil?
716
+ payload[:tags] = tags unless tags.nil?
717
+ payload[:default_branch] = default_branch unless default_branch.nil?
718
+
719
+ http_client.post(path, body: payload, timeout: timeout)
720
+ true
721
+ end
722
+
723
+ # Move or rename a repository.
724
+ #
725
+ # @param from_repo_id [String] The current ID of the repository.
726
+ # @param to_repo_id [String] The new ID for the repository.
727
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
728
+ # @param timeout [Numeric, nil] Request timeout in seconds.
729
+ # @return [Boolean] True if the repository was successfully moved/renamed.
730
+ # @raise [ArgumentError] If repo_ids or repo_type is invalid.
731
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the source repository does not exist.
732
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
733
+ def move_repo(from_repo_id:, to_repo_id:, repo_type: "model", timeout: nil)
734
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(from_repo_id)
735
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(to_repo_id)
736
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
737
+
738
+ path = "/api/#{repo_type}s/#{from_repo_id}/move"
739
+ payload = { newPath: to_repo_id }
740
+
741
+ http_client.post(path, body: payload, timeout: timeout)
742
+ true
743
+ end
744
+
745
+ # Duplicate a Space repository.
746
+ #
747
+ # @param from_repo_id [String] The ID of the Space to duplicate.
748
+ # @param to_repo_id [String] The ID for the new duplicated Space.
749
+ # @param private [Boolean, nil] Whether the new Space should be private. Defaults to the original Space's visibility.
750
+ # @param organization [String, nil] The organization namespace to create the new Space under.
751
+ # @param timeout [Numeric, nil] Request timeout in seconds.
752
+ # @return [String] The URL of the newly duplicated Space.
753
+ # @raise [ArgumentError] If repo_ids are invalid.
754
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the source Space does not exist.
755
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
756
+ def duplicate_space(from_repo_id:, to_repo_id:, private: nil, organization: nil, timeout: nil)
757
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(from_repo_id)
758
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(to_repo_id)
759
+
760
+ path = "/api/spaces/#{from_repo_id}/duplicate"
761
+ payload = { newPath: to_repo_id }
762
+ payload[:private] = private unless private.nil?
763
+ payload[:organization] = organization unless organization.nil?
764
+
765
+ response = http_client.post(path, body: payload, timeout: timeout)
766
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
767
+ body["url"]
768
+ end
769
+
770
+ # Upload a file to a repository on the HuggingFace Hub.
771
+ #
772
+ # @param repo_id [String] The ID of the repository.
773
+ # @param path_or_fileobj [String, Pathname, IO] The path to the file on the local filesystem, or an IO object.
774
+ # @param path_in_repo [String] The path to the file within the repository.
775
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
776
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to upload to. Defaults to "main".
777
+ # @param commit_message [String, nil] A custom commit message for the upload.
778
+ # @param commit_description [String, nil] A custom commit description.
779
+ # @param timeout [Numeric, nil] Request timeout in seconds.
780
+ # @return [String] The URL of the uploaded file.
781
+ # @raise [ArgumentError] If parameters are invalid.
782
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
783
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
784
+ def upload_file(
785
+ repo_id:,
786
+ path_or_fileobj:,
787
+ path_in_repo:,
788
+ repo_type: "model",
789
+ revision: nil,
790
+ commit_message: nil,
791
+ commit_description: nil,
792
+ timeout: nil
793
+ )
794
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
795
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
796
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
797
+ revision ||= "main"
798
+
799
+ path = "/api/#{repo_type}s/#{repo_id}/upload/#{path_in_repo}"
800
+ params = {
801
+ commit_message: commit_message || "Upload #{path_in_repo}",
802
+ commit_description: commit_description,
803
+ revision: revision
804
+ }.compact
805
+
806
+ file_content = if path_or_fileobj.is_a?(String) || path_or_fileobj.is_a?(Pathname)
807
+ Faraday::Multipart::FilePart.new(path_or_fileobj, "application/octet-stream", Pathname(path_or_fileobj).basename.to_s)
808
+ else # Assume IO object
809
+ Faraday::Multipart::FilePart.new(path_or_fileobj, "application/octet-stream")
810
+ end
811
+
812
+ payload = {
813
+ file: file_content
814
+ }
815
+
816
+ response = http_client.post(path, params: params, body: payload, timeout: timeout)
817
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
818
+ body["url"]
819
+ end
820
+
821
+ # Upload an entire folder to a repository on the HuggingFace Hub.
822
+ #
823
+ # This method iterates through all files in a local folder and uploads them
824
+ # to the specified repository, maintaining the folder structure.
825
+ #
826
+ # @param repo_id [String] The ID of the repository.
827
+ # @param folder_path [String, Pathname] The path to the local folder to upload.
828
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
829
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to upload to. Defaults to "main".
830
+ # @param commit_message [String, nil] A custom commit message for the upload.
831
+ # @param commit_description [String, nil] A custom commit description.
832
+ # @param timeout [Numeric, nil] Request timeout in seconds.
833
+ # @return [Array<String>] A list of URLs of the uploaded files.
834
+ # @raise [ArgumentError] If parameters are invalid or folder_path does not exist.
835
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
836
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
837
+ def upload_folder(
838
+ repo_id:,
839
+ folder_path:,
840
+ repo_type: "model",
841
+ revision: nil,
842
+ commit_message: nil,
843
+ commit_description: nil,
844
+ timeout: nil
845
+ )
846
+ folder_path = Pathname(folder_path)
847
+ raise ArgumentError, "Folder not found: #{folder_path}" unless folder_path.directory?
848
+
849
+ uploaded_urls = []
850
+ Dir.glob(File.join(folder_path, "**", "*")).each do |file_path_str|
851
+ file_path = Pathname(file_path_str)
852
+ next if file_path.directory?
853
+
854
+ relative_path = file_path.relative_path_from(folder_path).to_s
855
+
856
+ uploaded_urls << upload_file(
857
+ repo_id: repo_id,
858
+ path_or_fileobj: file_path,
859
+ path_in_repo: relative_path,
860
+ repo_type: repo_type,
861
+ revision: revision,
862
+ commit_message: commit_message,
863
+ commit_description: commit_description,
864
+ timeout: timeout
865
+ )
866
+ end
867
+ uploaded_urls
868
+ end
869
+
870
+ # Delete a file from a repository on the HuggingFace Hub.
871
+ #
872
+ # @param repo_id [String] The ID of the repository.
873
+ # @param path_in_repo [String] The path to the file within the repository to delete.
874
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
875
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to delete from. Defaults to "main".
876
+ # @param commit_message [String, nil] A custom commit message for the deletion.
877
+ # @param commit_description [String, nil] A custom commit description.
878
+ # @param timeout [Numeric, nil] Request timeout in seconds.
879
+ # @return [Boolean] True if the file was successfully deleted.
880
+ # @raise [ArgumentError] If parameters are invalid.
881
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
882
+ # @raise [DurableHuggingfaceHub::EntryNotFoundError] If the file does not exist in the repository.
883
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
884
+ def delete_file(
885
+ repo_id:,
886
+ path_in_repo:,
887
+ repo_type: "model",
888
+ revision: nil,
889
+ commit_message: nil,
890
+ commit_description: nil,
891
+ timeout: nil
892
+ )
893
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
894
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
895
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
896
+ revision ||= "main"
897
+
898
+ path = "/api/#{repo_type}s/#{repo_id}/upload/#{path_in_repo}"
899
+ params = {
900
+ commit_message: commit_message || "Delete #{path_in_repo}",
901
+ commit_description: commit_description,
902
+ revision: revision
903
+ }.compact
904
+
905
+ http_client.delete(path, params: params)
906
+ true
907
+ end
908
+
909
+ # Delete an entire folder from a repository on the HuggingFace Hub.
910
+ #
911
+ # This method lists all files within the specified folder in the repository
912
+ # and deletes them one by one.
913
+ #
914
+ # @param repo_id [String] The ID of the repository.
915
+ # @param folder_path_in_repo [String] The path to the folder within the repository to delete.
916
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
917
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to delete from. Defaults to "main".
918
+ # @param commit_message [String, nil] A custom commit message for the deletion.
919
+ # @param commit_description [String, nil] A custom commit description.
920
+ # @param timeout [Numeric, nil] Request timeout in seconds.
921
+ # @return [Boolean] True if the folder and its contents were successfully deleted.
922
+ # @raise [ArgumentError] If parameters are invalid.
923
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
924
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
925
+ def delete_folder(
926
+ repo_id:,
927
+ folder_path_in_repo:,
928
+ repo_type: "model",
929
+ revision: nil,
930
+ commit_message: nil,
931
+ commit_description: nil,
932
+ timeout: nil
933
+ )
934
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
935
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
936
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
937
+ revision ||= "main"
938
+
939
+ # List all files in the folder
940
+ files_to_delete = list_repo_files(
941
+ repo_id: repo_id,
942
+ repo_type: repo_type,
943
+ revision: revision,
944
+ timeout: timeout
945
+ ).select { |file_path| file_path.start_with?(folder_path_in_repo) }
946
+
947
+ # Delete each file
948
+ files_to_delete.each do |file_path|
949
+ delete_file(
950
+ repo_id: repo_id,
951
+ path_in_repo: file_path,
952
+ repo_type: repo_type,
953
+ revision: revision,
954
+ commit_message: commit_message || "Delete #{file_path} from #{folder_path_in_repo}",
955
+ commit_description: commit_description,
956
+ timeout: timeout
957
+ )
958
+ end
959
+ true
960
+ end
961
+
962
+ # Check if a file exists in a repository on the HuggingFace Hub.
963
+ #
964
+ # @param repo_id [String] The ID of the repository.
965
+ # @param path_in_repo [String] The path to the file within the repository to check.
966
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
967
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to check. Defaults to "main".
968
+ # @param timeout [Numeric, nil] Request timeout in seconds.
969
+ # @return [Boolean] True if the file exists, false otherwise.
970
+ # @raise [ArgumentError] If parameters are invalid.
971
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
972
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
973
+ def file_exists(repo_id:, path_in_repo:, repo_type: "model", revision: nil, timeout: nil)
974
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
975
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
976
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
977
+ revision ||= "main"
978
+
979
+ path = "/api/#{repo_type}s/#{repo_id}/resolve/#{revision}/#{path_in_repo}"
980
+ begin
981
+ http_client.head(path, timeout: timeout)
982
+ true
983
+ rescue DurableHuggingfaceHub::RepositoryNotFoundError, DurableHuggingfaceHub::EntryNotFoundError
984
+ false
985
+ end
986
+ end
987
+
988
+ # Create a new commit with multiple file operations.
989
+ #
990
+ # @param repo_id [String] The ID of the repository.
991
+ # @param operations [Array<Hash>] An array of file operations (add, delete, update).
992
+ # Each operation is a hash with at least a `:path` and `:operation` key.
993
+ # Example: [{ path: "file.txt", operation: "add", content: "new content" }]
994
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
995
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to commit to. Defaults to "main".
996
+ # @param commit_message [String, nil] A custom commit message for the upload.
997
+ # @param commit_description [String, nil] A custom commit description.
998
+ # @param parent_commit [String, nil] The SHA of the parent commit.
999
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1000
+ # @return [String] The SHA of the newly created commit.
1001
+ # @raise [ArgumentError] If parameters are invalid.
1002
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1003
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1004
+ def create_commit(
1005
+ repo_id:,
1006
+ operations:,
1007
+ repo_type: "model",
1008
+ revision: nil,
1009
+ commit_message: nil,
1010
+ commit_description: nil,
1011
+ parent_commit: nil,
1012
+ timeout: nil
1013
+ )
1014
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1015
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1016
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
1017
+ revision ||= "main"
1018
+
1019
+ path = "/api/#{repo_type}s/#{repo_id}/commits/#{revision}"
1020
+ payload = {
1021
+ operations: operations,
1022
+ commit_message: commit_message || "Commit from Ruby client",
1023
+ commit_description: commit_description,
1024
+ parent_commit: parent_commit
1025
+ }.compact
1026
+
1027
+ response = http_client.post(path, body: payload, timeout: timeout)
1028
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1029
+ body["commit_id"]
1030
+ end
1031
+
1032
+ # Create a new branch in a repository.
1033
+ #
1034
+ # @param repo_id [String] The ID of the repository.
1035
+ # @param branch_name [String] The name of the new branch.
1036
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1037
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to branch from. Defaults to "main".
1038
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1039
+ # @return [String] The name of the newly created branch.
1040
+ # @raise [ArgumentError] If parameters are invalid.
1041
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1042
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors (e.g., branch already exists).
1043
+ def create_branch(repo_id:, branch_name:, repo_type: "model", revision: nil, timeout: nil)
1044
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1045
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1046
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
1047
+ revision ||= "main"
1048
+
1049
+ path = "/api/#{repo_type}s/#{repo_id}/branches"
1050
+ payload = {
1051
+ name: branch_name,
1052
+ revision: revision
1053
+ }.compact
1054
+
1055
+ response = http_client.post(path, body: payload, timeout: timeout)
1056
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1057
+ body["name"]
1058
+ end
1059
+
1060
+ # Delete a branch from a repository.
1061
+ #
1062
+ # @param repo_id [String] The ID of the repository.
1063
+ # @param branch_name [String] The name of the branch to delete.
1064
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1065
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1066
+ # @return [Boolean] True if the branch was successfully deleted.
1067
+ # @raise [ArgumentError] If parameters are invalid.
1068
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1069
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the branch does not exist.
1070
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1071
+ def delete_branch(repo_id:, branch_name:, repo_type: "model", timeout: nil)
1072
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1073
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1074
+
1075
+ path = "/api/#{repo_type}s/#{repo_id}/branches/#{branch_name}"
1076
+ http_client.delete(path)
1077
+ true
1078
+ end
1079
+
1080
+ # Create a new tag in a repository.
1081
+ #
1082
+ # @param repo_id [String] The ID of the repository.
1083
+ # @param tag_name [String] The name of the new tag.
1084
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1085
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to tag from. Defaults to "main".
1086
+ # @param message [String, nil] An optional message for the tag.
1087
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1088
+ # @return [String] The name of the newly created tag.
1089
+ # @raise [ArgumentError] If parameters are invalid.
1090
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1091
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors (e.g., tag already exists).
1092
+ def create_tag(repo_id:, tag_name:, repo_type: "model", revision: nil, message: nil, timeout: nil)
1093
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1094
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1095
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
1096
+ revision ||= "main"
1097
+
1098
+ path = "/api/#{repo_type}s/#{repo_id}/tags"
1099
+ payload = {
1100
+ name: tag_name,
1101
+ revision: revision,
1102
+ message: message
1103
+ }.compact
1104
+
1105
+ response = http_client.post(path, body: payload, timeout: timeout)
1106
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1107
+ body["name"]
1108
+ end
1109
+
1110
+ # Delete a tag from a repository.
1111
+ #
1112
+ # @param repo_id [String] The ID of the repository.
1113
+ # @param tag_name [String] The name of the tag to delete.
1114
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1115
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1116
+ # @return [Boolean] True if the tag was successfully deleted.
1117
+ # @raise [ArgumentError] If parameters are invalid.
1118
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1119
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the tag does not exist.
1120
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1121
+ def delete_tag(repo_id:, tag_name:, repo_type: "model", timeout: nil)
1122
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1123
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1124
+
1125
+ path = "/api/#{repo_type}s/#{repo_id}/tags/#{tag_name}"
1126
+ http_client.delete(path)
1127
+ true
1128
+ end
1129
+
1130
+ # List branches and tags (refs) for a repository.
1131
+ #
1132
+ # @param repo_id [String] The ID of the repository.
1133
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1134
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1135
+ # @return [Hash] A hash containing arrays of "branches" and "tags".
1136
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
1137
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1138
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1139
+ def list_repo_refs(repo_id:, repo_type: "model", timeout: nil)
1140
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1141
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1142
+
1143
+ path = "/api/#{repo_type}s/#{repo_id}/refs"
1144
+ response = http_client.get(path, timeout: timeout)
1145
+
1146
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1147
+ branches = body["branches"].map { |branch_data| DurableHuggingfaceHub::Types::GitRefInfo.from_hash(branch_data) }
1148
+ tags = body["tags"].map { |tag_data| DurableHuggingfaceHub::Types::GitRefInfo.from_hash(tag_data) }
1149
+
1150
+ { branches: branches, tags: tags }
1151
+ end
1152
+
1153
+ # List commit history for a repository.
1154
+ #
1155
+ # @param repo_id [String] The ID of the repository.
1156
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1157
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to list commits from. Defaults to "main".
1158
+ # @param limit [Integer, nil] The maximum number of commits to return.
1159
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1160
+ # @return [Array<DurableHuggingfaceHub::Types::CommitInfo>] A list of commit information objects.
1161
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
1162
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1163
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the revision does not exist.
1164
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1165
+ def list_repo_commits(repo_id:, repo_type: "model", revision: nil, limit: nil, timeout: nil)
1166
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1167
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1168
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
1169
+ revision ||= "main"
1170
+
1171
+ path = "/api/#{repo_type}s/#{repo_id}/commits"
1172
+ params = {
1173
+ revision: revision,
1174
+ limit: limit
1175
+ }.compact
1176
+
1177
+ response = http_client.get(path, params: params, timeout: timeout)
1178
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1179
+ body.map do |commit_data|
1180
+ # Flatten the nested commit structure
1181
+ commit = commit_data["commit"]
1182
+ author = commit["author"]
1183
+ {
1184
+ oid: commit["id"],
1185
+ title: commit["message"]&.split("\n")&.first, # First line as title
1186
+ message: commit["message"],
1187
+ date: author ? Time.parse(author["date"]) : nil,
1188
+ authors: author ? [author["name"]] : nil
1189
+ }.compact
1190
+ end.map { |data| DurableHuggingfaceHub::Types::CommitInfo.from_hash(data) }
1191
+ end
1192
+
1193
+ # List Git LFS files in a repository.
1194
+ #
1195
+ # @param repo_id [String] The ID of the repository.
1196
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1197
+ # @param revision [String, nil] The Git revision (branch, tag, or commit SHA) to list LFS files from. Defaults to "main".
1198
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1199
+ # @return [Array<Hash>] A list of LFS file information hashes.
1200
+ # @raise [ArgumentError] If repo_id or repo_type is invalid.
1201
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1202
+ # @raise [DurableHuggingfaceHub::RevisionNotFoundError] If the revision does not exist.
1203
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1204
+ def list_lfs_files(repo_id:, repo_type: "model", revision: nil, timeout: nil)
1205
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1206
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1207
+ revision = DurableHuggingfaceHub::Utils::Validators.validate_revision(revision) if revision
1208
+ revision ||= "main"
1209
+
1210
+ path = "/api/#{repo_type}s/#{repo_id}/lfs/objects"
1211
+ params = { revision: revision }.compact
1212
+
1213
+ response = http_client.get(path, params: params, timeout: timeout)
1214
+ response.body.is_a?(String) ? JSON.parse(response.body) : response.body
1215
+ end
1216
+
1217
+ # Permanently delete LFS files from a repository.
1218
+ #
1219
+ # @param repo_id [String] The ID of the repository.
1220
+ # @param lfs_oids [Array<String>] A list of LFS object IDs (OIDs) to delete.
1221
+ # @param repo_type [String, Symbol] The type of the repository. Defaults to "model".
1222
+ # @param timeout [Numeric, nil] Request timeout in seconds.
1223
+ # @return [Boolean] True if the LFS files were successfully deleted.
1224
+ # @raise [ArgumentError] If parameters are invalid.
1225
+ # @raise [DurableHuggingfaceHub::RepositoryNotFoundError] If the repository does not exist.
1226
+ # @raise [DurableHuggingfaceHub::HfHubHTTPError] For other API errors.
1227
+ def permanently_delete_lfs_files(repo_id:, lfs_oids:, repo_type: "model", timeout: nil)
1228
+ DurableHuggingfaceHub::Utils::Validators.validate_repo_id(repo_id)
1229
+ repo_type = DurableHuggingfaceHub::Utils::Validators.validate_repo_type(repo_type)
1230
+
1231
+ path = "/api/#{repo_type}s/#{repo_id}/lfs/delete"
1232
+ payload = { oids: lfs_oids }
1233
+
1234
+ http_client.post(path, body: payload, timeout: timeout)
1235
+ true
1236
+ end
1237
+
1238
+ private
1239
+
1240
+ # Build query parameters for list endpoints
1241
+ #
1242
+ # @param filter [String, Hash, nil] Filter criteria
1243
+ # @param author [String, nil] Author filter
1244
+ # @param search [String, nil] Search query
1245
+ # @param sort [String, Symbol, nil] Sort criterion
1246
+ # @param direction [Integer, nil] Sort direction
1247
+ # @param limit [Integer, nil] Result limit
1248
+ # @param full [Boolean] Fetch full information
1249
+ #
1250
+ # @return [Hash] Query parameters
1251
+ def build_list_params(filter:, author:, search:, sort:, direction:, limit:, full:)
1252
+ params = {}
1253
+
1254
+ # Handle filter parameter
1255
+ if filter
1256
+ case filter
1257
+ when String
1258
+ # Single tag or search term
1259
+ params[:filter] = filter
1260
+ when Hash
1261
+ # Structured filters - convert to Hub API format
1262
+ filter.each do |key, value|
1263
+ params[key.to_s] = value
1264
+ end
1265
+ end
1266
+ end
1267
+
1268
+ params[:author] = author if author
1269
+ params[:search] = search if search
1270
+ params[:sort] = sort.to_s if sort
1271
+ params[:direction] = direction if direction
1272
+ params[:limit] = limit if limit
1273
+ params[:full] = full if full
1274
+
1275
+ params
1276
+ end
1277
+ end
1278
+ end