-
Notifications
You must be signed in to change notification settings - Fork 1
Add file snapshot functionality #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||
| import CryptoKit | ||||||
| import Foundation | ||||||
| import UniformTypeIdentifiers | ||||||
|
|
||||||
|
|
@@ -177,7 +178,7 @@ public extension HubClient { | |||||
| func downloadContentsOfFile( | ||||||
| at repoPath: String, | ||||||
| from repo: Repo.ID, | ||||||
| kind: Repo.Kind = .model, | ||||||
| kind _: Repo.Kind = .model, | ||||||
| revision: String = "main", | ||||||
| useRaw: Bool = false, | ||||||
| cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy | ||||||
|
|
@@ -208,7 +209,7 @@ public extension HubClient { | |||||
| at repoPath: String, | ||||||
| from repo: Repo.ID, | ||||||
| to destination: URL, | ||||||
| kind: Repo.Kind = .model, | ||||||
| kind _: Repo.Kind = .model, | ||||||
| revision: String = "main", | ||||||
| useRaw: Bool = false, | ||||||
| cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, | ||||||
|
|
@@ -298,9 +299,9 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega | |||||
| } | ||||||
|
|
||||||
| func urlSession( | ||||||
| _ session: URLSession, | ||||||
| downloadTask: URLSessionDownloadTask, | ||||||
| didWriteData bytesWritten: Int64, | ||||||
| _: URLSession, | ||||||
| downloadTask _: URLSessionDownloadTask, | ||||||
| didWriteData _: Int64, | ||||||
| totalBytesWritten: Int64, | ||||||
| totalBytesExpectedToWrite: Int64 | ||||||
| ) { | ||||||
|
|
@@ -309,9 +310,9 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega | |||||
| } | ||||||
|
|
||||||
| func urlSession( | ||||||
| _ session: URLSession, | ||||||
| downloadTask: URLSessionDownloadTask, | ||||||
| didFinishDownloadingTo location: URL | ||||||
| _: URLSession, | ||||||
| downloadTask _: URLSessionDownloadTask, | ||||||
| didFinishDownloadingTo _: URL | ||||||
| ) { | ||||||
| // The actual file handling is done in the async/await layer | ||||||
| } | ||||||
|
|
@@ -417,7 +418,7 @@ public extension HubClient { | |||||
| func getFile( | ||||||
| at repoPath: String, | ||||||
| in repo: Repo.ID, | ||||||
| kind: Repo.Kind = .model, | ||||||
| kind _: Repo.Kind = .model, | ||||||
| revision: String = "main" | ||||||
| ) async throws -> File { | ||||||
| let urlPath = "/\(repo)/resolve/\(revision)/\(repoPath)" | ||||||
|
|
@@ -452,6 +453,130 @@ public extension HubClient { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| // MARK: - Snapshot Download | ||||||
|
|
||||||
| public extension HubClient { | ||||||
| /// Download a repository snapshot to a local directory. | ||||||
| /// - Parameters: | ||||||
| /// - repo: Repository identifier | ||||||
| /// - kind: Kind of repository | ||||||
| /// - destination: Local destination directory | ||||||
| /// - revision: Git revision (branch, tag, or commit) | ||||||
| /// - matching: Glob patterns to filter files (empty array downloads all files) | ||||||
| /// - progressHandler: Optional closure called with progress updates | ||||||
| /// - Returns: URL to the local snapshot directory | ||||||
| func downloadSnapshot( | ||||||
| of repo: Repo.ID, | ||||||
| kind: Repo.Kind = .model, | ||||||
| to destination: URL, | ||||||
| revision: String = "main", | ||||||
| matching globs: [String] = [], | ||||||
| progressHandler: ((Progress) -> Void)? = nil | ||||||
| ) async throws -> URL { | ||||||
| let repoDestination = destination | ||||||
| let repoMetadataDestination = | ||||||
| repoDestination | ||||||
| .appendingPathComponent(".cache") | ||||||
| .appendingPathComponent("huggingface") | ||||||
| .appendingPathComponent("download") | ||||||
|
|
||||||
| let filenames = try await listFiles(in: repo, kind: kind, revision: revision, recursive: true) | ||||||
| .map(\.path) | ||||||
| .filter { filename in | ||||||
| guard !globs.isEmpty else { return true } | ||||||
| return globs.contains { glob in | ||||||
| fnmatch(glob, filename, 0) == 0 | ||||||
|
||||||
| } | ||||||
| } | ||||||
|
|
||||||
| let progress = Progress(totalUnitCount: Int64(filenames.count)) | ||||||
| progressHandler?(progress) | ||||||
|
|
||||||
| for filename in filenames { | ||||||
| let fileProgress = Progress(totalUnitCount: 100, parent: progress, pendingUnitCount: 1) | ||||||
|
|
||||||
| let fileDestination = repoDestination.appendingPathComponent(filename) | ||||||
| let metadataDestination = repoMetadataDestination.appendingPathComponent(filename + ".metadata") | ||||||
|
|
||||||
| let localMetadata = readDownloadMetadata(at: metadataDestination) | ||||||
| let remoteFile = try await getFile(at: filename, in: repo, kind: kind, revision: revision) | ||||||
|
|
||||||
| let localCommitHash = localMetadata?.commitHash ?? "" | ||||||
| let remoteCommitHash = remoteFile.revision ?? "" | ||||||
|
|
||||||
| if isValidHash(remoteCommitHash, pattern: commitHashPattern), | ||||||
| FileManager.default.fileExists(atPath: fileDestination.path), | ||||||
| localMetadata != nil, | ||||||
| localCommitHash == remoteCommitHash | ||||||
| { | ||||||
| fileProgress.completedUnitCount = 100 | ||||||
| continue | ||||||
| } | ||||||
|
|
||||||
| _ = try await downloadFile( | ||||||
| at: filename, | ||||||
| from: repo, | ||||||
| to: fileDestination, | ||||||
| kind: kind, | ||||||
| revision: revision, | ||||||
| progress: fileProgress | ||||||
| ) | ||||||
|
|
||||||
| if let etag = remoteFile.etag, let revision = remoteFile.revision { | ||||||
| try writeDownloadMetadata( | ||||||
| commitHash: revision, | ||||||
| etag: etag, | ||||||
| to: metadataDestination | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
| if Task.isCancelled { | ||||||
| return repoDestination | ||||||
|
||||||
| return repoDestination | |
| throw CancellationError() |
Copilot
AI
Nov 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using replacingOccurrences(of:with:) for extracting the filename is fragile. If a file legitimately contains '.metadata' in its name (e.g., 'model.metadata.json'), this will incorrectly strip that portion. Use deletingPathExtension() if '.metadata' is truly an extension, or String.removingSuffix() to only remove the suffix if it exists at the end.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
CryptoKitimport is unused in this file. TheLocalDownloadFileMetadatastruct only stores metadata as strings and does not perform any cryptographic operations. Consider removing this import sinceSHA256is only used inHubClient+Files.swiftwhere it's already imported.