Skip to content

Support offline mode with tests #5

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 236 additions & 119 deletions Sources/Hub/HubApi.swift
Original file line number Diff line number Diff line change
@@ -7,18 +7,21 @@

import Foundation
import CryptoKit
import Network
import os

public struct HubApi {
var downloadBase: URL
var hfToken: String?
var endpoint: String
var useBackgroundSession: Bool

var useOfflineMode: Bool? = nil

private let networkMonitor = NetworkMonitor()
public typealias RepoType = Hub.RepoType
public typealias Repo = Hub.Repo

public init(downloadBase: URL? = nil, hfToken: String? = nil, endpoint: String = "https://huggingface.co", useBackgroundSession: Bool = false) {
public init(downloadBase: URL? = nil, hfToken: String? = nil, endpoint: String = "https://huggingface.co", useBackgroundSession: Bool = false, useOfflineMode: Bool? = nil) {
self.hfToken = hfToken ?? Self.hfTokenFromEnv()
if let downloadBase {
self.downloadBase = downloadBase
@@ -28,8 +31,13 @@ public struct HubApi {
}
self.endpoint = endpoint
self.useBackgroundSession = useBackgroundSession
self.useOfflineMode = useOfflineMode
NetworkMonitor.shared.startMonitoring()
}

let sha256Pattern = "^[0-9a-f]{64}$"
let commitHashPattern = "^[0-9a-f]{40}$"

public static let shared = HubApi()

private static let logger = Logger()
@@ -153,10 +161,12 @@ public extension HubApi {
public extension HubApi {
enum EnvironmentError: LocalizedError {
case invalidMetadataError(String)

case offlineModeError(String)

public var errorDescription: String? {
switch self {
case .invalidMetadataError(let message):
case .invalidMetadataError(let message),
.offlineModeError(let message):
return message
}
}
@@ -202,17 +212,112 @@ public extension HubApi {
downloadBase.appending(component: repo.type.rawValue).appending(component: repo.id)
}

/// Reads metadata about a file in the local directory related to a download process.
///
/// Reference: https://github.com/huggingface/huggingface_hub/blob/b2c9a148d465b43ab90fab6e4ebcbbf5a9df27d4/src/huggingface_hub/_local_folder.py#L263
///
/// - Parameters:
/// - localDir: The local directory where metadata files are downloaded.
/// - filePath: The path of the file for which metadata is being read.
/// - Throws: An `EnvironmentError.invalidMetadataError` if the metadata file is invalid and cannot be removed.
/// - Returns: A `LocalDownloadFileMetadata` object if the metadata file exists and is valid, or `nil` if the file is missing or invalid.
func readDownloadMetadata(metadataPath: URL) throws -> LocalDownloadFileMetadata? {
if FileManager.default.fileExists(atPath: metadataPath.path) {
do {
let attributes = try FileManager.default.attributesOfItem(atPath: metadataPath.path)
print("File attributes: \(attributes)")
let contents = try String(contentsOf: metadataPath, encoding: .utf8)
let lines = contents.components(separatedBy: .newlines)

guard lines.count >= 3 else {
throw EnvironmentError.invalidMetadataError("Metadata file is missing required fields.")
}

let commitHash = lines[0].trimmingCharacters(in: .whitespacesAndNewlines)
let etag = lines[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let timestamp = Double(lines[2].trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw EnvironmentError.invalidMetadataError("Missing or invalid timestamp.")
}
let timestampDate = Date(timeIntervalSince1970: timestamp)

// TODO: check if file hasn't been modified since the metadata was saved
// Reference: https://github.com/huggingface/huggingface_hub/blob/2fdc6f48ef5e6b22ee9bcdc1945948ac070da675/src/huggingface_hub/_local_folder.py#L303

let filename = metadataPath.lastPathComponent.replacingOccurrences(of: ".metadata", with: "")

return LocalDownloadFileMetadata(commitHash: commitHash, etag: etag, filename: filename, timestamp: timestampDate)
} catch {
do {
HubApi.logger.warning("Invalid metadata file \(metadataPath): \(error). Removing it from disk and continue.")
try FileManager.default.removeItem(at: metadataPath)
} catch {
throw EnvironmentError.invalidMetadataError("Could not remove corrupted metadata file \(metadataPath): \(error)")
}
return nil
}
}

// metadata file does not exist
return nil
}

func isValidHash(hash: String, pattern: String) -> Bool {
let regex = try? NSRegularExpression(pattern: pattern)
let range = NSRange(location: 0, length: hash.utf16.count)
return regex?.firstMatch(in: hash, options: [], range: range) != nil
}

func computeFileHash(file url: URL) throws -> String {
// Open file for reading
guard let fileHandle = try? FileHandle(forReadingFrom: url) else {
throw Hub.HubClientError.unexpectedError
}

defer {
try? fileHandle.close()
}

var hasher = SHA256()
let chunkSize = 1024 * 1024 // 1MB chunks

while autoreleasepool(invoking: {
let nextChunk = try? fileHandle.read(upToCount: chunkSize)

guard let nextChunk,
!nextChunk.isEmpty
else {
return false
}

hasher.update(data: nextChunk)

return true
}) { }

let digest = hasher.finalize()
return digest.map { String(format: "%02x", $0) }.joined()
}

/// Reference: https://github.com/huggingface/huggingface_hub/blob/b2c9a148d465b43ab90fab6e4ebcbbf5a9df27d4/src/huggingface_hub/_local_folder.py#L391
func writeDownloadMetadata(commitHash: String, etag: String, metadataPath: URL) throws {
let metadataContent = "\(commitHash)\n\(etag)\n\(Date().timeIntervalSince1970)\n"
do {
try FileManager.default.createDirectory(at: metadataPath.deletingLastPathComponent(), withIntermediateDirectories: true)
try metadataContent.write(to: metadataPath, atomically: true, encoding: .utf8)
} catch {
throw EnvironmentError.invalidMetadataError("Failed to write metadata file \(metadataPath)")
}
}

struct HubFileDownloader {
let repo: Repo
let repoDestination: URL
let repoMetadataDestination: URL
let relativeFilename: String
let hfToken: String?
let endpoint: String?
let backgroundSession: Bool

let sha256Pattern = "^[0-9a-f]{64}$"
let commitHashPattern = "^[0-9a-f]{40}$"

var source: URL {
// https://huggingface.co/coreml-projects/Llama-2-7b-chat-coreml/resolve/main/tokenizer.json?download=true
var url = URL(string: endpoint ?? "https://huggingface.co")!
@@ -230,10 +335,7 @@ public extension HubApi {
}

var metadataDestination: URL {
repoDestination
.appendingPathComponent(".cache")
.appendingPathComponent("huggingface")
.appendingPathComponent("download")
repoMetadataDestination.appending(path: relativeFilename + ".metadata")
}

var downloaded: Bool {
@@ -246,121 +348,23 @@ public extension HubApi {
}

func prepareMetadataDestination() throws {
try FileManager.default.createDirectory(at: metadataDestination, withIntermediateDirectories: true, attributes: nil)
}

/// Reads metadata about a file in the local directory related to a download process.
///
/// Reference: https://github.com/huggingface/huggingface_hub/blob/b2c9a148d465b43ab90fab6e4ebcbbf5a9df27d4/src/huggingface_hub/_local_folder.py#L263
///
/// - Parameters:
/// - localDir: The local directory where metadata files are downloaded.
/// - filePath: The path of the file for which metadata is being read.
/// - Throws: An `EnvironmentError.invalidMetadataError` if the metadata file is invalid and cannot be removed.
/// - Returns: A `LocalDownloadFileMetadata` object if the metadata file exists and is valid, or `nil` if the file is missing or invalid.
func readDownloadMetadata(localDir: URL, filePath: String) throws -> LocalDownloadFileMetadata? {
let metadataPath = localDir.appending(path: filePath)
if FileManager.default.fileExists(atPath: metadataPath.path) {
do {
let contents = try String(contentsOf: metadataPath, encoding: .utf8)
let lines = contents.components(separatedBy: .newlines)

guard lines.count >= 3 else {
throw EnvironmentError.invalidMetadataError("Metadata file is missing required fields.")
}

let commitHash = lines[0].trimmingCharacters(in: .whitespacesAndNewlines)
let etag = lines[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let timestamp = Double(lines[2].trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw EnvironmentError.invalidMetadataError("Missing or invalid timestamp.")
}
let timestampDate = Date(timeIntervalSince1970: timestamp)

// TODO: check if file hasn't been modified since the metadata was saved
// Reference: https://github.com/huggingface/huggingface_hub/blob/2fdc6f48ef5e6b22ee9bcdc1945948ac070da675/src/huggingface_hub/_local_folder.py#L303

return LocalDownloadFileMetadata(commitHash: commitHash, etag: etag, filename: filePath, timestamp: timestampDate)
} catch {
do {
logger.warning("Invalid metadata file \(metadataPath): \(error). Removing it from disk and continue.")
try FileManager.default.removeItem(at: metadataPath)
} catch {
throw EnvironmentError.invalidMetadataError("Could not remove corrupted metadata file \(metadataPath): \(error)")
}
return nil
}
}

// metadata file does not exist
return nil
}

func isValidHash(hash: String, pattern: String) -> Bool {
let regex = try? NSRegularExpression(pattern: pattern)
let range = NSRange(location: 0, length: hash.utf16.count)
return regex?.firstMatch(in: hash, options: [], range: range) != nil
}

/// Reference: https://github.com/huggingface/huggingface_hub/blob/b2c9a148d465b43ab90fab6e4ebcbbf5a9df27d4/src/huggingface_hub/_local_folder.py#L391
func writeDownloadMetadata(commitHash: String, etag: String, metadataRelativePath: String) throws {
let metadataContent = "\(commitHash)\n\(etag)\n\(Date().timeIntervalSince1970)\n"
let metadataPath = metadataDestination.appending(component: metadataRelativePath)

do {
try FileManager.default.createDirectory(at: metadataPath.deletingLastPathComponent(), withIntermediateDirectories: true)
try metadataContent.write(to: metadataPath, atomically: true, encoding: .utf8)
} catch {
throw EnvironmentError.invalidMetadataError("Failed to write metadata file \(metadataPath)")
}
}

func computeFileHash(file url: URL) throws -> String {
// Open file for reading
guard let fileHandle = try? FileHandle(forReadingFrom: url) else {
throw Hub.HubClientError.unexpectedError
}

defer {
try? fileHandle.close()
}

var hasher = SHA256()
let chunkSize = 1024 * 1024 // 1MB chunks

while autoreleasepool(invoking: {
let nextChunk = try? fileHandle.read(upToCount: chunkSize)

guard let nextChunk,
!nextChunk.isEmpty
else {
return false
}

hasher.update(data: nextChunk)

return true
}) { }

let digest = hasher.finalize()
return digest.map { String(format: "%02x", $0) }.joined()
let directoryURL = metadataDestination.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
}


// Note we go from Combine in Downloader to callback-based progress reporting
// We'll probably need to support Combine as well to play well with Swift UI
// (See for example PipelineLoader in swift-coreml-diffusers)
@discardableResult
func download(progressHandler: @escaping (Double) -> Void) async throws -> URL {
let metadataRelativePath = "\(relativeFilename).metadata"

let localMetadata = try readDownloadMetadata(localDir: metadataDestination, filePath: metadataRelativePath)
let localMetadata = try HubApi.shared.readDownloadMetadata(metadataPath: metadataDestination)
let remoteMetadata = try await HubApi.shared.getFileMetadata(url: source)

let localCommitHash = localMetadata?.commitHash ?? ""
let remoteCommitHash = remoteMetadata.commitHash ?? ""

// Local file exists + metadata exists + commit_hash matches => return file
if isValidHash(hash: remoteCommitHash, pattern: commitHashPattern) && downloaded && localMetadata != nil && localCommitHash == remoteCommitHash {
if HubApi.shared.isValidHash(hash: remoteCommitHash, pattern: HubApi.shared.commitHashPattern) && downloaded && localMetadata != nil && localCommitHash == remoteCommitHash {
return destination
}

@@ -376,18 +380,18 @@ public extension HubApi {
if downloaded {
// etag matches => update metadata and return file
if localMetadata?.etag == remoteEtag {
try writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataRelativePath: metadataRelativePath)
try HubApi.shared.writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataPath: metadataDestination)
return destination
}

// etag is a sha256
// => means it's an LFS file (large)
// => let's compute local hash and compare
// => if match, update metadata and return file
if isValidHash(hash: remoteEtag, pattern: sha256Pattern) {
let fileHash = try computeFileHash(file: destination)
if HubApi.shared.isValidHash(hash: remoteEtag, pattern: HubApi.shared.sha256Pattern) {
let fileHash = try HubApi.shared.computeFileHash(file: destination)
if fileHash == remoteEtag {
try writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataRelativePath: metadataRelativePath)
try HubApi.shared.writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataPath: metadataDestination)
return destination
}
}
@@ -407,22 +411,63 @@ public extension HubApi {
try downloader.waitUntilDone()
}

try writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataRelativePath: metadataRelativePath)
try HubApi.shared.writeDownloadMetadata(commitHash: remoteCommitHash, etag: remoteEtag, metadataPath: metadataDestination)

return destination
}
}

@discardableResult
func snapshot(from repo: Repo, matching globs: [String] = [], progressHandler: @escaping (Progress) -> Void = { _ in }) async throws -> URL {
let repoDestination = localRepoLocation(repo)
let repoMetadataDestination = repoDestination
.appendingPathComponent(".cache")
.appendingPathComponent("huggingface")
.appendingPathComponent("download")

if useOfflineMode ?? NetworkMonitor.shared.shouldUseOfflineMode() {
if !FileManager.default.fileExists(atPath: repoDestination.path) {
throw EnvironmentError.offlineModeError("File not available locally in offline mode")
}

let fileUrls = try FileManager.default.getFileUrls(at: repoDestination)
if fileUrls.isEmpty {
throw EnvironmentError.offlineModeError("File not available locally in offline mode")
}

for fileUrl in fileUrls {
let metadataPath = URL(fileURLWithPath: fileUrl.path.replacingOccurrences(
of: repoDestination.path,
with: repoMetadataDestination.path
) + ".metadata")

let localMetadata = try readDownloadMetadata(metadataPath: metadataPath)

guard let localMetadata = localMetadata else {
throw EnvironmentError.offlineModeError("Metadata not available or invalid in offline mode")
}
let localEtag = localMetadata.etag

// LFS file so check file integrity
if self.isValidHash(hash: localEtag, pattern: self.sha256Pattern) {
let fileHash = try computeFileHash(file: fileUrl)
if fileHash != localEtag {
throw EnvironmentError.offlineModeError("File integrity check failed in offline mode")
}
}
}

return repoDestination
}

let filenames = try await getFilenames(from: repo, matching: globs)
let progress = Progress(totalUnitCount: Int64(filenames.count))
let repoDestination = localRepoLocation(repo)
for filename in filenames {
let fileProgress = Progress(totalUnitCount: 100, parent: progress, pendingUnitCount: 1)
let downloader = HubFileDownloader(
repo: repo,
repoDestination: repoDestination,
repoMetadataDestination: repoMetadataDestination,
relativeFilename: filename,
hfToken: hfToken,
endpoint: endpoint,
@@ -530,6 +575,50 @@ public extension HubApi {
}
}

/// Network monitor helper class to help decide whether to use offline mode
private extension HubApi {
private final class NetworkMonitor {
private var monitor: NWPathMonitor
private var queue: DispatchQueue

private(set) var isConnected: Bool = false
private(set) var isExpensive: Bool = false
private(set) var isConstrained: Bool = false

static let shared = NetworkMonitor()

init() {
monitor = NWPathMonitor()
queue = DispatchQueue(label: "HubApi.NetworkMonitor")
startMonitoring()
}

func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }

self.isConnected = path.status == .satisfied
self.isExpensive = path.isExpensive
self.isConstrained = path.isConstrained
}

monitor.start(queue: queue)
}

func stopMonitoring() {
monitor.cancel()
}

func shouldUseOfflineMode() -> Bool {
return !isConnected || isExpensive || isConstrained
}

deinit {
stopMonitoring()
}
}
}

/// Stateless wrappers that use `HubApi` instances
public extension Hub {
static func getFilenames(from repo: Hub.Repo, matching globs: [String] = []) async throws -> [String] {
@@ -595,6 +684,34 @@ public extension [String] {
}
}

public extension FileManager {
func getFileUrls(at directoryUrl: URL) throws -> [URL] {
var fileUrls = [URL]()

// Get all contents including subdirectories
guard let enumerator = FileManager.default.enumerator(
at: directoryUrl,
includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey],
options: [.skipsHiddenFiles]
) else {
return fileUrls
}

for case let fileURL as URL in enumerator {
do {
let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .isHiddenKey])
if resourceValues.isRegularFile == true && resourceValues.isHidden != true {
fileUrls.append(fileURL)
}
} catch {
throw error
}
}

return fileUrls
}
}

/// Only allow relative redirects and reject others
/// Reference: https://github.com/huggingface/huggingface_hub/blob/b2c9a148d465b43ab90fab6e4ebcbbf5a9df27d4/src/huggingface_hub/file_download.py#L258
private class RedirectDelegate: NSObject, URLSessionTaskDelegate {
172 changes: 158 additions & 14 deletions Tests/HubTests/HubApiTests.swift
Original file line number Diff line number Diff line change
@@ -655,21 +655,11 @@ class SnapshotDownloadTests: XCTestCase {
let commitHash = metadataArr[0]
let etag = metadataArr[1]

// Not needed for the downloads, just to test validation function
let downloader = HubApi.HubFileDownloader(
repo: Hub.Repo(id: lfsRepo),
repoDestination: downloadedTo,
relativeFilename: "x.bin",
hfToken: nil,
endpoint: nil,
backgroundSession: false
)

XCTAssertTrue(downloader.isValidHash(hash: commitHash, pattern: downloader.commitHashPattern))
XCTAssertTrue(downloader.isValidHash(hash: etag, pattern: downloader.sha256Pattern))
XCTAssertTrue(hubApi.isValidHash(hash: commitHash, pattern: hubApi.commitHashPattern))
XCTAssertTrue(hubApi.isValidHash(hash: etag, pattern: hubApi.sha256Pattern))

XCTAssertFalse(downloader.isValidHash(hash: "\(commitHash)a", pattern: downloader.commitHashPattern))
XCTAssertFalse(downloader.isValidHash(hash: "\(etag)a", pattern: downloader.sha256Pattern))
XCTAssertFalse(hubApi.isValidHash(hash: "\(commitHash)a", pattern: hubApi.commitHashPattern))
XCTAssertFalse(hubApi.isValidHash(hash: "\(etag)a", pattern: hubApi.sha256Pattern))
}

func testLFSFileNoMetadata() async throws {
@@ -824,4 +814,158 @@ class SnapshotDownloadTests: XCTestCase {

XCTAssertTrue(metadataString.contains(expected))
}

func testOfflineModeReturnsDestination() async throws {
var hubApi = HubApi(downloadBase: downloadDestination)
var lastProgress: Progress? = nil

var downloadedTo = try await hubApi.snapshot(from: repo, matching: "*.json") { progress in
print("Total Progress: \(progress.fractionCompleted)")
print("Files Completed: \(progress.completedUnitCount) of \(progress.totalUnitCount)")

lastProgress = progress
}

XCTAssertEqual(lastProgress?.fractionCompleted, 1)
XCTAssertEqual(lastProgress?.completedUnitCount, 6)
XCTAssertEqual(downloadedTo, downloadDestination.appending(path: "models/\(repo)"))

hubApi = HubApi(downloadBase: downloadDestination, useOfflineMode: true)

downloadedTo = try await hubApi.snapshot(from: repo, matching: "*.json") { progress in
print("Total Progress: \(progress.fractionCompleted)")
print("Files Completed: \(progress.completedUnitCount) of \(progress.totalUnitCount)")
lastProgress = progress
}

XCTAssertEqual(lastProgress?.fractionCompleted, 1)
XCTAssertEqual(lastProgress?.completedUnitCount, 6)
XCTAssertEqual(downloadedTo, downloadDestination.appending(path: "models/\(repo)"))
}

func testOfflineModeThrowsError() async throws {
let hubApi = HubApi(downloadBase: downloadDestination, useOfflineMode: true)

do {
try await hubApi.snapshot(from: repo, matching: "*.json")
XCTFail("Expected an error to be thrown")
} catch let error as HubApi.EnvironmentError {
switch error {
case .offlineModeError(let message):
XCTAssertEqual(message, "File not available locally in offline mode")
default:
XCTFail("Wrong error type: \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}

func testOfflineModeWithoutMetadata() async throws {
var hubApi = HubApi(downloadBase: downloadDestination)
var lastProgress: Progress? = nil

let downloadedTo = try await hubApi.snapshot(from: lfsRepo, matching: "*") { progress in
print("Total Progress: \(progress.fractionCompleted)")
print("Files Completed: \(progress.completedUnitCount) of \(progress.totalUnitCount)")

lastProgress = progress
}

XCTAssertEqual(lastProgress?.fractionCompleted, 1)
XCTAssertEqual(lastProgress?.completedUnitCount, 2)
XCTAssertEqual(downloadedTo, downloadDestination.appending(path: "models/\(lfsRepo)"))

let metadataDestination = downloadedTo.appending(component: ".cache/huggingface/download")

let metadataFile = metadataDestination.appendingPathComponent("x.bin.metadata")
try FileManager.default.removeItem(atPath: metadataFile.path)

hubApi = HubApi(downloadBase: downloadDestination, useOfflineMode: true)

do {
try await hubApi.snapshot(from: lfsRepo, matching: "*")
XCTFail("Expected an error to be thrown")
} catch let error as HubApi.EnvironmentError {
switch error {
case .offlineModeError(let message):
XCTAssertEqual(message, "Metadata not available or invalid in offline mode")
default:
XCTFail("Wrong error type: \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}

func testOfflineModeWithCorruptedLFSMetadata() async throws {
var hubApi = HubApi(downloadBase: downloadDestination)
var lastProgress: Progress? = nil

let downloadedTo = try await hubApi.snapshot(from: lfsRepo, matching: "*") { progress in
print("Total Progress: \(progress.fractionCompleted)")
print("Files Completed: \(progress.completedUnitCount) of \(progress.totalUnitCount)")

lastProgress = progress
}

XCTAssertEqual(lastProgress?.fractionCompleted, 1)
XCTAssertEqual(lastProgress?.completedUnitCount, 2)
XCTAssertEqual(downloadedTo, downloadDestination.appending(path: "models/\(lfsRepo)"))

let metadataDestination = downloadedTo.appendingPathComponent(".cache/huggingface/download").appendingPathComponent("x.bin.metadata")

try "77b984598d90af6143d73d5a2d6214b23eba7e27\n98ea6e4f216f2ab4b69fff9b3a44842c38686ca685f3f55dc48c5d3fb1107be4\n0\n".write(to: metadataDestination, atomically: true, encoding: .utf8)

hubApi = HubApi(downloadBase: downloadDestination, useOfflineMode: true)

do {
try await hubApi.snapshot(from: lfsRepo, matching: "*")
XCTFail("Expected an error to be thrown")
} catch let error as HubApi.EnvironmentError {
switch error {
case .offlineModeError(let message):
XCTAssertEqual(message, "File integrity check failed in offline mode")
default:
XCTFail("Wrong error type: \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}

func testOfflineModeWithNoFiles() async throws {
var hubApi = HubApi(downloadBase: downloadDestination)
var lastProgress: Progress? = nil

let downloadedTo = try await hubApi.snapshot(from: lfsRepo, matching: "x.bin") { progress in
print("Total Progress: \(progress.fractionCompleted)")
print("Files Completed: \(progress.completedUnitCount) of \(progress.totalUnitCount)")

lastProgress = progress
}

XCTAssertEqual(lastProgress?.fractionCompleted, 1)
XCTAssertEqual(lastProgress?.completedUnitCount, 1)
XCTAssertEqual(downloadedTo, downloadDestination.appending(path: "models/\(lfsRepo)"))

let fileDestination = downloadedTo.appendingPathComponent("x.bin")
try FileManager.default.removeItem(at: fileDestination)

hubApi = HubApi(downloadBase: downloadDestination, useOfflineMode: true)

do {
try await hubApi.snapshot(from: lfsRepo, matching: "x.bin")
XCTFail("Expected an error to be thrown")
} catch let error as HubApi.EnvironmentError {
switch error {
case .offlineModeError(let message):
XCTAssertEqual(message, "File not available locally in offline mode")
default:
XCTFail("Wrong error type: \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}