1+ import CryptoKit
12import Foundation
23import UniformTypeIdentifiers
34
@@ -177,7 +178,7 @@ public extension HubClient {
177178 func downloadContentsOfFile(
178179 at repoPath: String ,
179180 from repo: Repo . ID ,
180- kind: Repo . Kind = . model,
181+ kind _ : Repo . Kind = . model,
181182 revision: String = " main " ,
182183 useRaw: Bool = false ,
183184 cachePolicy: URLRequest . CachePolicy = . useProtocolCachePolicy
@@ -208,7 +209,7 @@ public extension HubClient {
208209 at repoPath: String ,
209210 from repo: Repo . ID ,
210211 to destination: URL ,
211- kind: Repo . Kind = . model,
212+ kind _ : Repo . Kind = . model,
212213 revision: String = " main " ,
213214 useRaw: Bool = false ,
214215 cachePolicy: URLRequest . CachePolicy = . useProtocolCachePolicy,
@@ -298,9 +299,9 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
298299 }
299300
300301 func urlSession(
301- _ session : URLSession ,
302- downloadTask: URLSessionDownloadTask ,
303- didWriteData bytesWritten : Int64 ,
302+ _: URLSession ,
303+ downloadTask _ : URLSessionDownloadTask ,
304+ didWriteData _ : Int64 ,
304305 totalBytesWritten: Int64 ,
305306 totalBytesExpectedToWrite: Int64
306307 ) {
@@ -309,9 +310,9 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
309310 }
310311
311312 func urlSession(
312- _ session : URLSession ,
313- downloadTask: URLSessionDownloadTask ,
314- didFinishDownloadingTo location : URL
313+ _: URLSession ,
314+ downloadTask _ : URLSessionDownloadTask ,
315+ didFinishDownloadingTo _ : URL
315316 ) {
316317 // The actual file handling is done in the async/await layer
317318 }
@@ -417,7 +418,7 @@ public extension HubClient {
417418 func getFile(
418419 at repoPath: String ,
419420 in repo: Repo . ID ,
420- kind: Repo . Kind = . model,
421+ kind _ : Repo . Kind = . model,
421422 revision: String = " main "
422423 ) async throws -> File {
423424 let urlPath = " / \( repo) /resolve/ \( revision) / \( repoPath) "
@@ -452,6 +453,130 @@ public extension HubClient {
452453 }
453454}
454455
456+ // MARK: - Snapshot Download
457+
458+ public extension HubClient {
459+ /// Download a repository snapshot to a local directory.
460+ /// - Parameters:
461+ /// - repo: Repository identifier
462+ /// - kind: Kind of repository
463+ /// - destination: Local destination directory
464+ /// - revision: Git revision (branch, tag, or commit)
465+ /// - matching: Glob patterns to filter files (empty array downloads all files)
466+ /// - progressHandler: Optional closure called with progress updates
467+ /// - Returns: URL to the local snapshot directory
468+ func downloadSnapshot(
469+ of repo: Repo . ID ,
470+ kind: Repo . Kind = . model,
471+ to destination: URL ,
472+ revision: String = " main " ,
473+ matching globs: [ String ] = [ ] ,
474+ progressHandler: ( ( Progress ) -> Void ) ? = nil
475+ ) async throws -> URL {
476+ let repoDestination = destination
477+ let repoMetadataDestination =
478+ repoDestination
479+ . appendingPathComponent ( " .cache " )
480+ . appendingPathComponent ( " huggingface " )
481+ . appendingPathComponent ( " download " )
482+
483+ let filenames = try await listFiles ( in: repo, kind: kind, revision: revision, recursive: true )
484+ . map ( \. path)
485+ . filter { filename in
486+ guard !globs. isEmpty else { return true }
487+ return globs. contains { glob in
488+ fnmatch ( glob, filename, 0 ) == 0
489+ }
490+ }
491+
492+ let progress = Progress ( totalUnitCount: Int64 ( filenames. count) )
493+ progressHandler ? ( progress)
494+
495+ for filename in filenames {
496+ let fileProgress = Progress ( totalUnitCount: 100 , parent: progress, pendingUnitCount: 1 )
497+
498+ let fileDestination = repoDestination. appendingPathComponent ( filename)
499+ let metadataDestination = repoMetadataDestination. appendingPathComponent ( filename + " .metadata " )
500+
501+ let localMetadata = readDownloadMetadata ( at: metadataDestination)
502+ let remoteFile = try await getFile ( at: filename, in: repo, kind: kind, revision: revision)
503+
504+ let localCommitHash = localMetadata? . commitHash ?? " "
505+ let remoteCommitHash = remoteFile. revision ?? " "
506+
507+ if isValidHash ( remoteCommitHash, pattern: commitHashPattern) ,
508+ FileManager . default. fileExists ( atPath: fileDestination. path) ,
509+ localMetadata != nil ,
510+ localCommitHash == remoteCommitHash
511+ {
512+ fileProgress. completedUnitCount = 100
513+ continue
514+ }
515+
516+ _ = try await downloadFile (
517+ at: filename,
518+ from: repo,
519+ to: fileDestination,
520+ kind: kind,
521+ revision: revision,
522+ progress: fileProgress
523+ )
524+
525+ if let etag = remoteFile. etag, let revision = remoteFile. revision {
526+ try writeDownloadMetadata (
527+ commitHash: revision,
528+ etag: etag,
529+ to: metadataDestination
530+ )
531+ }
532+
533+ if Task . isCancelled {
534+ return repoDestination
535+ }
536+
537+ fileProgress. completedUnitCount = 100
538+ }
539+
540+ progressHandler ? ( progress)
541+ return repoDestination
542+ }
543+ }
544+
545+ // MARK: - Metadata Helpers
546+
547+ extension HubClient {
548+ private var sha256Pattern : String { " ^[0-9a-f]{64}$ " }
549+ private var commitHashPattern : String { " ^[0-9a-f]{40}$ " }
550+
551+ /// Read metadata about a file in the local directory.
552+ func readDownloadMetadata( at metadataPath: URL ) -> LocalDownloadFileMetadata ? {
553+ FileManager . default. readDownloadMetadata ( at: metadataPath)
554+ }
555+
556+ /// Write metadata about a downloaded file.
557+ func writeDownloadMetadata( commitHash: String , etag: String , to metadataPath: URL ) throws {
558+ try FileManager . default. writeDownloadMetadata (
559+ commitHash: commitHash,
560+ etag: etag,
561+ to: metadataPath
562+ )
563+ }
564+
565+ /// Check if a hash matches the expected pattern.
566+ func isValidHash( _ hash: String , pattern: String ) -> Bool {
567+ guard let regex = try ? NSRegularExpression ( pattern: pattern) else {
568+ return false
569+ }
570+ let range = NSRange ( location: 0 , length: hash. utf16. count)
571+ return regex. firstMatch ( in: hash, options: [ ] , range: range) != nil
572+ }
573+
574+ /// Compute SHA256 hash of a file.
575+ func computeFileHash( at url: URL ) throws -> String {
576+ try FileManager . default. computeFileHash ( at: url)
577+ }
578+ }
579+
455580// MARK: -
456581
457582private struct UploadResponse : Codable {
@@ -461,6 +586,90 @@ private struct UploadResponse: Codable {
461586
462587// MARK: -
463588
589+ private extension FileManager {
590+ /// Read metadata about a file in the local directory.
591+ func readDownloadMetadata( at metadataPath: URL ) -> LocalDownloadFileMetadata ? {
592+ guard fileExists ( atPath: metadataPath. path) else {
593+ return nil
594+ }
595+
596+ do {
597+ let contents = try String ( contentsOf: metadataPath, encoding: . utf8)
598+ let lines = contents. components ( separatedBy: . newlines)
599+
600+ guard lines. count >= 3 else {
601+ try ? removeItem ( at: metadataPath)
602+ return nil
603+ }
604+
605+ let commitHash = lines [ 0 ] . trimmingCharacters ( in: . whitespacesAndNewlines)
606+ let etag = lines [ 1 ] . trimmingCharacters ( in: . whitespacesAndNewlines)
607+
608+ guard let timestamp = Double ( lines [ 2 ] . trimmingCharacters ( in: . whitespacesAndNewlines) )
609+ else {
610+ try ? removeItem ( at: metadataPath)
611+ return nil
612+ }
613+
614+ let timestampDate = Date ( timeIntervalSince1970: timestamp)
615+ let filename = metadataPath. lastPathComponent. replacingOccurrences (
616+ of: " .metadata " ,
617+ with: " "
618+ )
619+
620+ return LocalDownloadFileMetadata (
621+ commitHash: commitHash,
622+ etag: etag,
623+ filename: filename,
624+ timestamp: timestampDate
625+ )
626+ } catch {
627+ try ? removeItem ( at: metadataPath)
628+ return nil
629+ }
630+ }
631+
632+ /// Write metadata about a downloaded file.
633+ func writeDownloadMetadata( commitHash: String , etag: String , to metadataPath: URL ) throws {
634+ let metadataContent = " \( commitHash) \n \( etag) \n \( Date ( ) . timeIntervalSince1970) \n "
635+ try createDirectory (
636+ at: metadataPath. deletingLastPathComponent ( ) ,
637+ withIntermediateDirectories: true
638+ )
639+ try metadataContent. write ( to: metadataPath, atomically: true , encoding: . utf8)
640+ }
641+
642+ /// Compute SHA256 hash of a file.
643+ func computeFileHash( at url: URL ) throws -> String {
644+ guard let fileHandle = try ? FileHandle ( forReadingFrom: url) else {
645+ throw HTTPClientError . unexpectedError ( " Unable to open file: \( url. path) " )
646+ }
647+
648+ defer {
649+ try ? fileHandle. close ( )
650+ }
651+
652+ var hasher = SHA256 ( )
653+ let chunkSize = 1024 * 1024
654+
655+ while autoreleasepool ( invoking: {
656+ guard let nextChunk = try ? fileHandle. read ( upToCount: chunkSize) ,
657+ !nextChunk. isEmpty
658+ else {
659+ return false
660+ }
661+
662+ hasher. update ( data: nextChunk)
663+ return true
664+ } ) { }
665+
666+ let digest = hasher. finalize ( )
667+ return digest. map { String ( format: " %02x " , $0) } . joined ( )
668+ }
669+ }
670+
671+ // MARK: -
672+
464673private extension URL {
465674 var mimeType : String ? {
466675 guard let uti = UTType ( filenameExtension: pathExtension) else {
0 commit comments