From eb882bd5289be4ed081443c5b7e66700565275af Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 11:00:54 +0100 Subject: [PATCH 01/37] Added swift-crypto to replace CryptoKit on Linux. Renamed imports to Crypto (instead of CryptoKit) and added some small changes to make some files compile on Linux --- .vscode/settings.json | 4 +++ Package.resolved | 33 +++++++++++++++++++ Package.swift | 6 ++-- Sources/HuggingFace/Hub/File.swift | 2 +- Sources/HuggingFace/Hub/HubClient+Files.swift | 16 ++++++--- .../HuggingFaceAuthenticationManager.swift | 2 ++ Sources/HuggingFace/OAuth/OAuthClient.swift | 6 ++-- 7 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 Package.resolved diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ff18881 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:pyenv", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..a085437 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "5528436ea38567cfdec6c3339dac8e9fae159e719492bef6ae7e4883079cd06b", + "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 90abb1d..74bdab8 100644 --- a/Package.swift +++ b/Package.swift @@ -20,13 +20,15 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/mattt/EventSource.git", from: "1.0.0") + .package(url: "https://github.com/mattt/EventSource.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "5.0.0"), ], targets: [ .target( name: "HuggingFace", dependencies: [ - .product(name: "EventSource", package: "EventSource") + .product(name: "EventSource", package: "EventSource"), + .product(name: "Crypto", package: "swift-crypto"), ], path: "Sources/HuggingFace" ), diff --git a/Sources/HuggingFace/Hub/File.swift b/Sources/HuggingFace/Hub/File.swift index bf47c07..4248a0c 100644 --- a/Sources/HuggingFace/Hub/File.swift +++ b/Sources/HuggingFace/Hub/File.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation /// Information about a file in a repository. diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index e88ec14..5ede124 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -1,5 +1,8 @@ import Foundation -import UniformTypeIdentifiers + +#if canImport(UniformTypeIdentifiers) + import UniformTypeIdentifiers +#endif #if canImport(FoundationNetworking) import FoundationNetworking @@ -599,9 +602,14 @@ private struct UploadResponse: Codable { private extension URL { var mimeType: String? { - guard let uti = UTType(filenameExtension: pathExtension) else { + #if canImport(UniformTypeIdentifiers) + guard let uti = UTType(filenameExtension: pathExtension) else { + return nil + } + return uti.preferredMIMEType + #else + // TODO: see how we can get the equivalent of UTType/mimetype on linux return nil - } - return uti.preferredMIMEType + #endif } } diff --git a/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift b/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift index e194651..fbad644 100644 --- a/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift +++ b/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift @@ -1,3 +1,5 @@ +import Foundation + #if canImport(AuthenticationServices) import AuthenticationServices import Observation diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index 1bc7d33..0069c30 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -1,5 +1,5 @@ -#if canImport(CryptoKit) - import CryptoKit +#if canImport(Crypto) + import Crypto import Foundation #if canImport(FoundationNetworking) @@ -365,4 +365,4 @@ .replacingOccurrences(of: "=", with: "") } } -#endif // canImport(CryptoKit) +#endif // canImport(Crypto) From f7f50f98d4baf776532a8c1de9e8759071e99982 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 11:08:52 +0100 Subject: [PATCH 02/37] HTTPURLResponse on Linux is made available via FoundationNetworking. Added platform conditional import for Linux --- Sources/HuggingFace/Hub/Pagination.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/HuggingFace/Hub/Pagination.swift b/Sources/HuggingFace/Hub/Pagination.swift index daa591f..f01f76e 100644 --- a/Sources/HuggingFace/Hub/Pagination.swift +++ b/Sources/HuggingFace/Hub/Pagination.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif /// Sort direction for list queries. public enum SortDirection: Int, Hashable, Sendable { From 09a8b30f04f066f7b0b033a237589e373354f11e Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 11:57:44 +0100 Subject: [PATCH 03/37] Removed precompile #if because we are including the Crypto library now as a package dependency --- Sources/HuggingFace/OAuth/OAuthClient.swift | 652 ++++++++++---------- 1 file changed, 325 insertions(+), 327 deletions(-) diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index 0069c30..b65ff50 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -1,368 +1,366 @@ -#if canImport(Crypto) - import Crypto - import Foundation - - #if canImport(FoundationNetworking) - import FoundationNetworking - #endif // canImport(FoundationNetworking) - - /// An OAuth 2.0 client for handling authentication flows - /// with support for token caching, refresh, and secure code exchange - /// using PKCE (Proof Key for Code Exchange). - public actor OAuthClient: Sendable { - /// The OAuth client configuration. - public let configuration: OAuthClientConfiguration - - /// The URL session to use for network requests. - let urlSession: URLSession - - private var cachedToken: OAuthToken? - private var refreshTask: Task? - private var codeVerifier: String? - - /// Initializes a new OAuth client with the specified configuration. - /// - Parameters: - /// - configuration: The OAuth configuration containing client credentials and endpoints. - /// - session: The URL session to use for network requests. Defaults to `.shared`. - public init(configuration: OAuthClientConfiguration, session: URLSession = .shared) { - self.configuration = configuration - self.urlSession = session +import Crypto +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif // canImport(FoundationNetworking) + +/// An OAuth 2.0 client for handling authentication flows +/// with support for token caching, refresh, and secure code exchange +/// using PKCE (Proof Key for Code Exchange). +public actor OAuthClient: Sendable { + /// The OAuth client configuration. + public let configuration: OAuthClientConfiguration + + /// The URL session to use for network requests. + let urlSession: URLSession + + private var cachedToken: OAuthToken? + private var refreshTask: Task? + private var codeVerifier: String? + + /// Initializes a new OAuth client with the specified configuration. + /// - Parameters: + /// - configuration: The OAuth configuration containing client credentials and endpoints. + /// - session: The URL session to use for network requests. Defaults to `.shared`. + public init(configuration: OAuthClientConfiguration, session: URLSession = .shared) { + self.configuration = configuration + self.urlSession = session + } + + /// Retrieves a valid OAuth token, using cached token if available and valid. + /// + /// This method first checks for a valid cached token. If no valid token exists and a refresh + /// is already in progress, it waits for that refresh to complete. If no refresh is in progress, + /// it throws `OAuthError.authenticationRequired` to indicate that fresh authentication is needed. + /// + /// - Returns: A valid OAuth token. + /// - Throws: `OAuthError.authenticationRequired` if no valid token is available and no refresh is in progress. + public func getValidToken() async throws -> OAuthToken { + // Return cached token if valid + if let token = cachedToken, token.isValid { + return token } - /// Retrieves a valid OAuth token, using cached token if available and valid. - /// - /// This method first checks for a valid cached token. If no valid token exists and a refresh - /// is already in progress, it waits for that refresh to complete. If no refresh is in progress, - /// it throws `OAuthError.authenticationRequired` to indicate that fresh authentication is needed. - /// - /// - Returns: A valid OAuth token. - /// - Throws: `OAuthError.authenticationRequired` if no valid token is available and no refresh is in progress. - public func getValidToken() async throws -> OAuthToken { - // Return cached token if valid - if let token = cachedToken, token.isValid { - return token - } - - // If refresh already in progress, wait for it - if let task = refreshTask { - return try await task.value - } - - // No valid token and no refresh in progress - need fresh authentication - throw OAuthError.authenticationRequired + // If refresh already in progress, wait for it + if let task = refreshTask { + return try await task.value } - /// Initiates the OAuth authentication flow using PKCE (Proof Key for Code Exchange). - /// - /// This method generates PKCE values, constructs the authorization URL, and presents - /// a web authentication session to the user. The user will be redirected to the OAuth - /// provider's authorization page where they can grant permissions. - /// - /// - Parameter handler: A closure that handles the authentication session flow. - /// - Returns: The authorization code from the OAuth callback. - /// - Throws: `OAuthError.sessionFailedToStart` if the authentication session cannot be started. - /// - Throws: `OAuthError.invalidCallback` if the callback URL is invalid or doesn't contain an authorization code. - public func authenticate(handler: @escaping (URL, String) async throws -> String) - async throws -> String - { - // Generate PKCE values - let (verifier, challenge) = Self.generatePKCEValues() - self.codeVerifier = verifier - - // Build authorization URL - let authURL = configuration.baseURL.appendingPathComponent("oauth/authorize") - var components = URLComponents(url: authURL, resolvingAgainstBaseURL: false)! - components.queryItems = [ - .init(name: "client_id", value: configuration.clientID), - .init(name: "redirect_uri", value: configuration.redirectURL.absoluteString), - .init(name: "response_type", value: "code"), - .init(name: "scope", value: configuration.scope), - .init(name: "code_challenge", value: challenge), - .init(name: "code_challenge_method", value: "S256"), - .init(name: "state", value: UUID().uuidString), - ] - - guard let finalAuthURL = components.url, - let scheme = configuration.redirectURL.scheme - else { - throw OAuthError.sessionFailedToStart - } - - return try await handler(finalAuthURL, scheme) + // No valid token and no refresh in progress - need fresh authentication + throw OAuthError.authenticationRequired + } + + /// Initiates the OAuth authentication flow using PKCE (Proof Key for Code Exchange). + /// + /// This method generates PKCE values, constructs the authorization URL, and presents + /// a web authentication session to the user. The user will be redirected to the OAuth + /// provider's authorization page where they can grant permissions. + /// + /// - Parameter handler: A closure that handles the authentication session flow. + /// - Returns: The authorization code from the OAuth callback. + /// - Throws: `OAuthError.sessionFailedToStart` if the authentication session cannot be started. + /// - Throws: `OAuthError.invalidCallback` if the callback URL is invalid or doesn't contain an authorization code. + public func authenticate(handler: @escaping (URL, String) async throws -> String) + async throws -> String + { + // Generate PKCE values + let (verifier, challenge) = Self.generatePKCEValues() + self.codeVerifier = verifier + + // Build authorization URL + let authURL = configuration.baseURL.appendingPathComponent("oauth/authorize") + var components = URLComponents(url: authURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + .init(name: "client_id", value: configuration.clientID), + .init(name: "redirect_uri", value: configuration.redirectURL.absoluteString), + .init(name: "response_type", value: "code"), + .init(name: "scope", value: configuration.scope), + .init(name: "code_challenge", value: challenge), + .init(name: "code_challenge_method", value: "S256"), + .init(name: "state", value: UUID().uuidString), + ] + + guard let finalAuthURL = components.url, + let scheme = configuration.redirectURL.scheme + else { + throw OAuthError.sessionFailedToStart } - /// Exchanges an authorization code for an OAuth token using PKCE. - /// - /// This method takes the authorization code received from the OAuth callback and exchanges - /// it for an access token and refresh token. The code verifier generated during authentication - /// is used to complete the PKCE flow for security. - /// - /// - Parameter code: The authorization code from the OAuth callback. - /// - Returns: An OAuth token containing access and refresh tokens. - /// - Throws: `OAuthError.missingCodeVerifier` if no code verifier is available. - /// - Throws: `OAuthError.tokenExchangeFailed` if the token exchange request fails. - public func exchangeCode(_ code: String) async throws -> OAuthToken { - guard let verifier = codeVerifier else { - throw OAuthError.missingCodeVerifier - } - - let tokenURL = configuration.baseURL.appendingPathComponent("oauth/token") - var request = URLRequest(url: tokenURL) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - var components = URLComponents() - components.queryItems = [ - .init(name: "grant_type", value: "authorization_code"), - .init(name: "code", value: code), - .init(name: "redirect_uri", value: configuration.redirectURL.absoluteString), - .init(name: "client_id", value: configuration.clientID), - .init(name: "code_verifier", value: verifier), - ] - request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - - let (data, response) = try await urlSession.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - (200 ... 299).contains(httpResponse.statusCode) - else { - throw OAuthError.tokenExchangeFailed - } - - let tokenResponse = try await MainActor.run { - try JSONDecoder().decode(TokenResponse.self, from: data) - } - let token = OAuthToken( - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) - ) - - self.cachedToken = token - self.codeVerifier = nil + return try await handler(finalAuthURL, scheme) + } - return token + /// Exchanges an authorization code for an OAuth token using PKCE. + /// + /// This method takes the authorization code received from the OAuth callback and exchanges + /// it for an access token and refresh token. The code verifier generated during authentication + /// is used to complete the PKCE flow for security. + /// + /// - Parameter code: The authorization code from the OAuth callback. + /// - Returns: An OAuth token containing access and refresh tokens. + /// - Throws: `OAuthError.missingCodeVerifier` if no code verifier is available. + /// - Throws: `OAuthError.tokenExchangeFailed` if the token exchange request fails. + public func exchangeCode(_ code: String) async throws -> OAuthToken { + guard let verifier = codeVerifier else { + throw OAuthError.missingCodeVerifier } - /// Refreshes an OAuth token using a refresh token. - /// - /// This method prevents multiple concurrent refresh operations by tracking an active refresh task. - /// If a refresh is already in progress, it waits for that refresh to complete rather than - /// starting a new one. - /// - /// - Parameter refreshToken: The refresh token to use for obtaining a new access token. - /// - Returns: A new OAuth token with updated access and refresh tokens. - /// - Throws: `OAuthError.tokenExchangeFailed` if the refresh request fails. - public func refreshToken(using refreshToken: String) async throws -> OAuthToken { - // Start refresh task if not already running - if let task = refreshTask { - return try await task.value - } - - let task = Task { - try await performRefresh(refreshToken: refreshToken) - } - refreshTask = task - - defer { - Task { clearRefreshTask() } - } + let tokenURL = configuration.baseURL.appendingPathComponent("oauth/token") + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var components = URLComponents() + components.queryItems = [ + .init(name: "grant_type", value: "authorization_code"), + .init(name: "code", value: code), + .init(name: "redirect_uri", value: configuration.redirectURL.absoluteString), + .init(name: "client_id", value: configuration.clientID), + .init(name: "code_verifier", value: verifier), + ] + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) + else { + throw OAuthError.tokenExchangeFailed + } - return try await task.value + let tokenResponse = try await MainActor.run { + try JSONDecoder().decode(TokenResponse.self, from: data) } + let token = OAuthToken( + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) + ) + + self.cachedToken = token + self.codeVerifier = nil + + return token + } - private func clearRefreshTask() { - refreshTask = nil + /// Refreshes an OAuth token using a refresh token. + /// + /// This method prevents multiple concurrent refresh operations by tracking an active refresh task. + /// If a refresh is already in progress, it waits for that refresh to complete rather than + /// starting a new one. + /// + /// - Parameter refreshToken: The refresh token to use for obtaining a new access token. + /// - Returns: A new OAuth token with updated access and refresh tokens. + /// - Throws: `OAuthError.tokenExchangeFailed` if the refresh request fails. + public func refreshToken(using refreshToken: String) async throws -> OAuthToken { + // Start refresh task if not already running + if let task = refreshTask { + return try await task.value } - private func performRefresh(refreshToken: String) async throws -> OAuthToken { - let tokenURL = configuration.baseURL.appendingPathComponent("oauth/token") - var request = URLRequest(url: tokenURL) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - var components = URLComponents() - components.queryItems = [ - .init(name: "grant_type", value: "refresh_token"), - .init(name: "refresh_token", value: refreshToken), - .init(name: "client_id", value: configuration.clientID), - ] - request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - - let (data, response) = try await urlSession.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - (200 ... 299).contains(httpResponse.statusCode) - else { - throw OAuthError.tokenExchangeFailed - } - - let tokenResponse = try await MainActor.run { - try JSONDecoder().decode(TokenResponse.self, from: data) - } - let token = OAuthToken( - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken ?? refreshToken, - expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) - ) - - self.cachedToken = token - return token + let task = Task { + try await performRefresh(refreshToken: refreshToken) } + refreshTask = task - /// Generates PKCE code verifier and challenge values as a tuple. - /// - Returns: A tuple containing the code verifier and its corresponding challenge. - private static func generatePKCEValues() -> (verifier: String, challenge: String) { - // Generate a cryptographically secure random code verifier - var buffer = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) - let verifier = Data(buffer).urlSafeBase64EncodedString() - .trimmingCharacters(in: .whitespaces) - - // Generate SHA256 hash of the verifier for the challenge - let data = Data(verifier.utf8) - let hashed = SHA256.hash(data: data) - let challenge = Data(hashed).urlSafeBase64EncodedString() - - return (verifier, challenge) + defer { + Task { clearRefreshTask() } } + + return try await task.value } - // MARK: - - - /// Configuration for OAuth authentication client - public struct OAuthClientConfiguration: Sendable { - /// The base URL for OAuth endpoints - public let baseURL: URL - - /// The redirect URL for OAuth callbacks - public let redirectURL: URL - - /// The OAuth client ID - public let clientID: String - - /// The scopes for OAuth requests as a space-separated string - public let scope: String - - /// Initializes a new OAuth configuration with the specified parameters. - /// - Parameters: - /// - baseURL: The base URL for OAuth endpoints. - /// - redirectURL: The redirect URL for OAuth callbacks. - /// - clientID: The OAuth client ID. - /// - scope: The scopes for OAuth requests. - public init( - baseURL: URL, - redirectURL: URL, - clientID: String, - scope: String - ) { - self.baseURL = baseURL - self.redirectURL = redirectURL - self.clientID = clientID - self.scope = scope + private func clearRefreshTask() { + refreshTask = nil + } + + private func performRefresh(refreshToken: String) async throws -> OAuthToken { + let tokenURL = configuration.baseURL.appendingPathComponent("oauth/token") + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + var components = URLComponents() + components.queryItems = [ + .init(name: "grant_type", value: "refresh_token"), + .init(name: "refresh_token", value: refreshToken), + .init(name: "client_id", value: configuration.clientID), + ] + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) + else { + throw OAuthError.tokenExchangeFailed + } + + let tokenResponse = try await MainActor.run { + try JSONDecoder().decode(TokenResponse.self, from: data) } + let token = OAuthToken( + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken ?? refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)) + ) + + self.cachedToken = token + return token } - // MARK: - + /// Generates PKCE code verifier and challenge values as a tuple. + /// - Returns: A tuple containing the code verifier and its corresponding challenge. + private static func generatePKCEValues() -> (verifier: String, challenge: String) { + // Generate a cryptographically secure random code verifier + var buffer = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + let verifier = Data(buffer).urlSafeBase64EncodedString() + .trimmingCharacters(in: .whitespaces) + + // Generate SHA256 hash of the verifier for the challenge + let data = Data(verifier.utf8) + let hashed = SHA256.hash(data: data) + let challenge = Data(hashed).urlSafeBase64EncodedString() + + return (verifier, challenge) + } +} + +// MARK: - + +/// Configuration for OAuth authentication client +public struct OAuthClientConfiguration: Sendable { + /// The base URL for OAuth endpoints + public let baseURL: URL + + /// The redirect URL for OAuth callbacks + public let redirectURL: URL + + /// The OAuth client ID + public let clientID: String + + /// The scopes for OAuth requests as a space-separated string + public let scope: String + + /// Initializes a new OAuth configuration with the specified parameters. + /// - Parameters: + /// - baseURL: The base URL for OAuth endpoints. + /// - redirectURL: The redirect URL for OAuth callbacks. + /// - clientID: The OAuth client ID. + /// - scope: The scopes for OAuth requests. + public init( + baseURL: URL, + redirectURL: URL, + clientID: String, + scope: String + ) { + self.baseURL = baseURL + self.redirectURL = redirectURL + self.clientID = clientID + self.scope = scope + } +} - /// OAuth token containing access and refresh tokens - public struct OAuthToken: Sendable, Codable { - /// The access token - public let accessToken: String +// MARK: - - /// The refresh token - public let refreshToken: String? +/// OAuth token containing access and refresh tokens +public struct OAuthToken: Sendable, Codable { + /// The access token + public let accessToken: String - /// The expiration date of the token - public let expiresAt: Date + /// The refresh token + public let refreshToken: String? - /// Whether the token is valid - public var isValid: Bool { - Date() < expiresAt.addingTimeInterval(-300) // 5 min buffer - } + /// The expiration date of the token + public let expiresAt: Date - /// Initializes a new OAuth token with the specified parameters. - /// - Parameters: - /// - accessToken: The access token. - /// - refreshToken: The refresh token. - /// - expiresAt: The expiration date of the token. - public init(accessToken: String, refreshToken: String?, expiresAt: Date) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.expiresAt = expiresAt - } + /// Whether the token is valid + public var isValid: Bool { + Date() < expiresAt.addingTimeInterval(-300) // 5 min buffer + } - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresAt = "expires_at" - } + /// Initializes a new OAuth token with the specified parameters. + /// - Parameters: + /// - accessToken: The access token. + /// - refreshToken: The refresh token. + /// - expiresAt: The expiration date of the token. + public init(accessToken: String, refreshToken: String?, expiresAt: Date) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt } - /// OAuth error enum - public enum OAuthError: LocalizedError, Equatable, Sendable { - /// Authentication required - case authenticationRequired + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresAt = "expires_at" + } +} - /// Invalid callback - case invalidCallback +/// OAuth error enum +public enum OAuthError: LocalizedError, Equatable, Sendable { + /// Authentication required + case authenticationRequired - /// Session failed to start - case sessionFailedToStart + /// Invalid callback + case invalidCallback - /// Missing code verifier - case missingCodeVerifier + /// Session failed to start + case sessionFailedToStart - /// Token exchange failed - case tokenExchangeFailed + /// Missing code verifier + case missingCodeVerifier - /// Token storage error - case tokenStorageError(String) + /// Token exchange failed + case tokenExchangeFailed - /// Invalid configuration - case invalidConfiguration(String) + /// Token storage error + case tokenStorageError(String) - /// The error description - public var errorDescription: String? { - switch self { - case .authenticationRequired: return "Authentication required" - case .invalidCallback: return "Invalid callback" - case .sessionFailedToStart: return "Session failed to start" - case .missingCodeVerifier: return "Missing code verifier" - case .tokenExchangeFailed: return "Token exchange failed" - case .tokenStorageError(let error): return "Token storage error: \(error)" - case .invalidConfiguration(let error): return "Invalid configuration: \(error)" - } - } - } + /// Invalid configuration + case invalidConfiguration(String) - private struct TokenResponse: Sendable, Codable { - let accessToken: String - let refreshToken: String? - let expiresIn: Int - let tokenType: String - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - case tokenType = "token_type" + /// The error description + public var errorDescription: String? { + switch self { + case .authenticationRequired: return "Authentication required" + case .invalidCallback: return "Invalid callback" + case .sessionFailedToStart: return "Session failed to start" + case .missingCodeVerifier: return "Missing code verifier" + case .tokenExchangeFailed: return "Token exchange failed" + case .tokenStorageError(let error): return "Token storage error: \(error)" + case .invalidConfiguration(let error): return "Invalid configuration: \(error)" } } - - // MARK: - - - private extension Data { - /// Returns a URL-safe Base64 encoded string suitable for use in URLs and OAuth flows. - /// - /// This method applies the standard Base64 encoding and then replaces characters - /// that are not URL-safe (+ becomes -, / becomes _, = padding is removed). - /// - Returns: A URL-safe Base64 encoded string. - func urlSafeBase64EncodedString() -> String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } +} + +private struct TokenResponse: Sendable, Codable { + let accessToken: String + let refreshToken: String? + let expiresIn: Int + let tokenType: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case tokenType = "token_type" + } +} + +// MARK: - + +private extension Data { + /// Returns a URL-safe Base64 encoded string suitable for use in URLs and OAuth flows. + /// + /// This method applies the standard Base64 encoding and then replaces characters + /// that are not URL-safe (+ becomes -, / becomes _, = padding is removed). + /// - Returns: A URL-safe Base64 encoded string. + func urlSafeBase64EncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") } -#endif // canImport(Crypto) +} From 8f4d3d462352d6f4692359f061bc07b6e8c4ff27 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 12:05:43 +0100 Subject: [PATCH 04/37] Replaced use of SecRandomCopyBytes by SystemRandomNumberGenerator on non macOS platforms --- Sources/HuggingFace/OAuth/OAuthClient.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index b65ff50..4fd3f7b 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -214,7 +214,14 @@ public actor OAuthClient: Sendable { private static func generatePKCEValues() -> (verifier: String, challenge: String) { // Generate a cryptographically secure random code verifier var buffer = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + #if os(macOS) + _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + #else + // This should be cryptographically secure, see: https://forums.swift.org/t/random-data-uint8-random-or-secrandomcopybytes/56165/9 + var generator = SystemRandomNumberGenerator() + buffer = buffer.map { _ in UInt8.random(in: 0 ... 8, using: &generator) } + #endif + let verifier = Data(buffer).urlSafeBase64EncodedString() .trimmingCharacters(in: .whitespaces) From 027e945686d533b7348a88fefe6dffaa7a02618d Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 12:24:21 +0100 Subject: [PATCH 05/37] The download overload to resume a download from a Data object is not available in swift-corelibs-foundation. --- Sources/HuggingFace/Hub/HubClient+Files.swift | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index 5ede124..4b947b5 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -308,29 +308,31 @@ public extension HubClient { return destination } - /// Download file with resume capability - /// - Parameters: - /// - resumeData: Resume data from a previous download attempt - /// - destination: Destination URL for downloaded file - /// - progress: Optional Progress object to track download progress - /// - Returns: Final destination URL - func resumeDownloadFile( - resumeData: Data, - to destination: URL, - progress: Progress? = nil - ) async throws -> URL { - let (tempURL, response) = try await session.download( - resumeFrom: resumeData, - delegate: progress.map { DownloadProgressDelegate(progress: $0) } - ) - _ = try httpClient.validateResponse(response, data: nil) + #if !canImport(FoundationNetworking) + /// Download file with resume capability + /// - Parameters: + /// - resumeData: Resume data from a previous download attempt + /// - destination: Destination URL for downloaded file + /// - progress: Optional Progress object to track download progress + /// - Returns: Final destination URL + func resumeDownloadFile( + resumeData: Data, + to destination: URL, + progress: Progress? = nil + ) async throws -> URL { + let (tempURL, response) = try await session.download( + resumeFrom: resumeData, + delegate: progress.map { DownloadProgressDelegate(progress: $0) } + ) + _ = try httpClient.validateResponse(response, data: nil) - // Move from temporary location to final destination - try? FileManager.default.removeItem(at: destination) - try FileManager.default.moveItem(at: tempURL, to: destination) + // Move from temporary location to final destination + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: tempURL, to: destination) - return destination - } + return destination + } + #endif /// Download file to a destination URL (convenience method without progress tracking) /// - Parameters: From ad0756a6b4f2cfc8165939e48ccc055225051111 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 12:35:02 +0100 Subject: [PATCH 06/37] Added other Apple platforms to the precompiler directive --- Sources/HuggingFace/OAuth/OAuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index 4fd3f7b..3b7b782 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -214,7 +214,7 @@ public actor OAuthClient: Sendable { private static func generatePKCEValues() -> (verifier: String, challenge: String) { // Generate a cryptographically secure random code verifier var buffer = [UInt8](repeating: 0, count: 32) - #if os(macOS) + #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) #else // This should be cryptographically secure, see: https://forums.swift.org/t/random-data-uint8-random-or-secrandomcopybytes/56165/9 From a46b33a885860ab29a225d85829d9806cd48a9e4 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 13:34:55 +0100 Subject: [PATCH 07/37] Added an alternative for the streaming URLRequest.bytes(for:) function. --- Sources/HuggingFace/Shared/HTTPClient.swift | 18 +++- Sources/HuggingFace/Shared/Streaming.swift | 102 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 Sources/HuggingFace/Shared/Streaming.swift diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index 66801f2..95ee92b 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -124,9 +124,9 @@ final class HTTPClient: @unchecked Sendable { let task = Task { do { let request = try await createRequest(method, path, params: params, headers: headers) - let (bytes, response) = try await session.bytes(for: request) + /*let (bytes, response) = try await session.bytes(for: request) let httpResponse = try validateResponse(response) - + guard (200 ..< 300).contains(httpResponse.statusCode) else { var errorData = Data() for try await byte in bytes { @@ -135,9 +135,19 @@ final class HTTPClient: @unchecked Sendable { // validateResponse will throw the appropriate error _ = try validateResponse(response, data: errorData) return // This line will never be reached, but satisfies the compiler - } + }*/ - for try await event in bytes.events { + for try await event in streamEvents( + request: request, + configuration: session.configuration, + onResponse: { [weak self] response in + guard let self else { + return + } + + _ = try validateResponse(response) + } + ) { // Check for [DONE] signal if event.data.trimmingCharacters(in: .whitespacesAndNewlines) == "[DONE]" { continuation.finish() diff --git a/Sources/HuggingFace/Shared/Streaming.swift b/Sources/HuggingFace/Shared/Streaming.swift new file mode 100644 index 0000000..9a0e900 --- /dev/null +++ b/Sources/HuggingFace/Shared/Streaming.swift @@ -0,0 +1,102 @@ +import Foundation +import EventSource + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class StreamingDelegate: NSObject, URLSessionDataDelegate { + private let continuation: AsyncThrowingStream.Continuation + private let parser = EventSource.Parser() + private let onResponse: @Sendable (HTTPURLResponse) throws -> Void + + init( + continuation: AsyncThrowingStream.Continuation, + onResponse: @escaping @Sendable (HTTPURLResponse) throws -> Void + ) { + self.continuation = continuation + self.onResponse = onResponse + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void + ) { + guard + let http = response + as? HTTPURLResponse /*, + (200 ..< 300).contains(http.statusCode), + http.value(forHTTPHeaderField: "Content-Type")?.lowercased().hasPrefix("text/event-stream") == true*/ + else { + completionHandler(.cancel) + continuation.finish(throwing: URLError(.badServerResponse)) + return + } + + do { + try onResponse(http) + completionHandler(.allow) + } catch { + completionHandler(.cancel) + continuation.finish(throwing: error) + } + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + Task { + for byte in data { + await parser.consume(byte) + while let event = await parser.getNextEvent() { + continuation.yield(event) + } + } + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + Task { + await parser.finish() + if let event = await parser.getNextEvent() { + continuation.yield(event) + } + if let error { continuation.finish(throwing: error) } else { continuation.finish() } + } + } +} + +func streamEvents( + request: URLRequest, + configuration: URLSessionConfiguration = .default, + onResponse: @escaping @Sendable (HTTPURLResponse) throws -> Void +) + -> AsyncThrowingStream +{ + AsyncThrowingStream { continuation in + let delegate = StreamingDelegate( + continuation: continuation, + onResponse: onResponse + ) + let session = URLSession( + configuration: configuration, + delegate: delegate, + delegateQueue: nil + ) + let task = session.dataTask(with: request) + task.resume() + + continuation.onTermination = { _ in + task.cancel() + session.invalidateAndCancel() + } + } +} From 4dcf8465b6e1f91d97dc52f573e4a1644eac1b85 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Mon, 8 Dec 2025 13:55:56 +0100 Subject: [PATCH 08/37] Added conditional import of FoundationNetworking for all tests that need it --- Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/CollectionTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/DatasetTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/DiscussionTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/ModelTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/OrganizationTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/PaperTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/RepoTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/SpaceTests.swift | 4 ++++ Tests/HuggingFaceTests/HubTests/UserTests.swift | 4 ++++ .../InferenceProvidersTests/ChatCompletionTests.swift | 3 +++ .../InferenceProvidersTests/FeatureExtractionTests.swift | 4 ++++ .../InferenceProvidersTests/InferenceClientTests.swift | 5 +++++ .../InferenceProvidersTests/SpeechToTextTests.swift | 4 ++++ .../InferenceProvidersTests/TextToImageTests.swift | 4 ++++ .../InferenceProvidersTests/TextToVideoTests.swift | 4 ++++ .../OAuthTests/HuggingFaceAuthenticationManagerTests.swift | 2 +- Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift | 3 +++ 19 files changed, 72 insertions(+), 1 deletion(-) diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index 64afc1b..bd195d8 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace // MARK: - Request Handler Storage diff --git a/Tests/HuggingFaceTests/HubTests/CollectionTests.swift b/Tests/HuggingFaceTests/HubTests/CollectionTests.swift index 20b5bb4..9e1d9ef 100644 --- a/Tests/HuggingFaceTests/HubTests/CollectionTests.swift +++ b/Tests/HuggingFaceTests/HubTests/CollectionTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift index 3a6c93c..17c17ed 100644 --- a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift +++ b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift b/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift index 6c62ba6..eb04630 100644 --- a/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift +++ b/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift index d5d4150..3fd1519 100644 --- a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift +++ b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/ModelTests.swift b/Tests/HuggingFaceTests/HubTests/ModelTests.swift index b047ef4..f57ed42 100644 --- a/Tests/HuggingFaceTests/HubTests/ModelTests.swift +++ b/Tests/HuggingFaceTests/HubTests/ModelTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift index 873b865..348f51a 100644 --- a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift +++ b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/PaperTests.swift b/Tests/HuggingFaceTests/HubTests/PaperTests.swift index 6d4aba7..3005cc7 100644 --- a/Tests/HuggingFaceTests/HubTests/PaperTests.swift +++ b/Tests/HuggingFaceTests/HubTests/PaperTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/RepoTests.swift b/Tests/HuggingFaceTests/HubTests/RepoTests.swift index 8f30e97..a79e0d8 100644 --- a/Tests/HuggingFaceTests/HubTests/RepoTests.swift +++ b/Tests/HuggingFaceTests/HubTests/RepoTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/SpaceTests.swift b/Tests/HuggingFaceTests/HubTests/SpaceTests.swift index 5124dba..74303c4 100644 --- a/Tests/HuggingFaceTests/HubTests/SpaceTests.swift +++ b/Tests/HuggingFaceTests/HubTests/SpaceTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/UserTests.swift b/Tests/HuggingFaceTests/HubTests/UserTests.swift index 8a63f4f..b206891 100644 --- a/Tests/HuggingFaceTests/HubTests/UserTests.swift +++ b/Tests/HuggingFaceTests/HubTests/UserTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift index 92066b3..ccdcf1d 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift @@ -1,5 +1,8 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift index 937c015..bc7f8ae 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift index d42fd69..bf78e89 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift @@ -1,6 +1,11 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift index dab10d7..abc6d8a 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift index 9cf856f..4220e3e 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift index 98c42e0..99526e1 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift b/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift index 2db58ad..693ef26 100644 --- a/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift +++ b/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift @@ -3,7 +3,7 @@ import Testing @testable import HuggingFace -#if swift(>=6.1) +#if swift(>=6.1) && canImport(AuthenticationServices) @Suite("HuggingFace Authentication Manager Tests") struct HuggingFaceAuthenticationManagerTests { @Test("HuggingFaceAuthenticationManager can be initialized with valid parameters") diff --git a/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift b/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift index 131b792..d09ae80 100644 --- a/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift +++ b/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift @@ -1,5 +1,8 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif @testable import HuggingFace From a7239521871c105b4cfd3b7a274b597a5abcdb7b Mon Sep 17 00:00:00 2001 From: "Pedro N. Rodriguez" Date: Mon, 8 Dec 2025 17:38:29 +0100 Subject: [PATCH 09/37] builds and tests pass on Linux --- Package.resolved | 33 +++ Package.swift | 6 +- Sources/HuggingFace/Hub/File.swift | 1 - Sources/HuggingFace/Hub/HubClient+Files.swift | 179 ++++++++----- Sources/HuggingFace/Hub/Pagination.swift | 64 +++-- .../HuggingFaceAuthenticationManager.swift | 20 +- Sources/HuggingFace/OAuth/OAuthClient.swift | 67 ++--- Sources/HuggingFace/OAuth/TokenStorage.swift | 194 ++++++++++++++ Sources/HuggingFace/Shared/HTTPClient.swift | 115 ++++++--- .../HuggingFace/Shared/URLSession+Linux.swift | 237 ++++++++++++++++++ .../Helpers/MockURLProtocol.swift | 28 ++- .../HubTests/CacheLocationProviderTests.swift | 4 + .../HubTests/CollectionTests.swift | 4 + .../HubTests/DatasetTests.swift | 4 + .../HubTests/DiscussionTests.swift | 4 + .../HubTests/FileLockTests.swift | 4 + .../HubTests/FileOperationsTests.swift | 4 + .../HubTests/GatedModeTests.swift | 4 + .../HuggingFaceTests/HubTests/GitTests.swift | 4 + .../HubTests/HubCacheTests.swift | 4 + .../HubTests/HubClientTests.swift | 4 + .../HubTests/ModelTests.swift | 4 + .../HubTests/OrganizationTests.swift | 4 + .../HubTests/PaginationTests.swift | 26 +- .../HubTests/PaperTests.swift | 4 + .../HubTests/RepoIDTests.swift | 4 + .../HuggingFaceTests/HubTests/RepoTests.swift | 4 + .../HubTests/SpaceTests.swift | 4 + .../HuggingFaceTests/HubTests/UserTests.swift | 4 + .../ChatCompletionTests.swift | 4 + .../FeatureExtractionTests.swift | 4 + .../InferenceClientTests.swift | 4 + .../SpeechToTextTests.swift | 4 + .../TextToImageTests.swift | 4 + .../TextToVideoTests.swift | 4 + ...uggingFaceAuthenticationManagerTests.swift | 10 +- .../OAuthTests/OAuthClientTests.swift | 4 + 37 files changed, 896 insertions(+), 180 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/HuggingFace/OAuth/TokenStorage.swift create mode 100644 Sources/HuggingFace/Shared/URLSession+Linux.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7a51450 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "7a114691c4b649fe05acbb3173248bb8019b7b44a054539af0aaf0a91eb7436c", + "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 90abb1d..cadfc87 100644 --- a/Package.swift +++ b/Package.swift @@ -20,13 +20,15 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/mattt/EventSource.git", from: "1.0.0") + .package(url: "https://github.com/mattt/EventSource.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), ], targets: [ .target( name: "HuggingFace", dependencies: [ - .product(name: "EventSource", package: "EventSource") + .product(name: "EventSource", package: "EventSource"), + .product(name: "Crypto", package: "swift-crypto"), ], path: "Sources/HuggingFace" ), diff --git a/Sources/HuggingFace/Hub/File.swift b/Sources/HuggingFace/Hub/File.swift index bf47c07..663d3ef 100644 --- a/Sources/HuggingFace/Hub/File.swift +++ b/Sources/HuggingFace/Hub/File.swift @@ -1,4 +1,3 @@ -import CryptoKit import Foundation /// Information about a file in a repository. diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index e88ec14..55bd5a1 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -1,5 +1,8 @@ import Foundation -import UniformTypeIdentifiers + +#if canImport(UniformTypeIdentifiers) + import UniformTypeIdentifiers +#endif #if canImport(FoundationNetworking) import FoundationNetworking @@ -72,7 +75,11 @@ public extension HubClient { .buildToTempFile() defer { try? FileManager.default.removeItem(at: tempFile) } - let (data, response) = try await session.upload(for: request, fromFile: tempFile) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncUpload(for: request, fromFile: tempFile) + #else + let (data, response) = try await session.upload(for: request, fromFile: tempFile) + #endif _ = try httpClient.validateResponse(response, data: data) if data.isEmpty { @@ -89,7 +96,11 @@ public extension HubClient { .addFile(name: "file", fileURL: fileURL, mimeType: mimeType) .buildInMemory() - let (data, response) = try await session.upload(for: request, from: body) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncUpload(for: request, from: body) + #else + let (data, response) = try await session.upload(for: request, from: body) + #endif _ = try httpClient.validateResponse(response, data: data) if data.isEmpty { @@ -199,7 +210,11 @@ public extension HubClient { var request = try await httpClient.createRequest(.get, urlPath) request.cachePolicy = cachePolicy - let (data, response) = try await session.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncData(for: request) + #else + let (data, response) = try await session.data(for: request) + #endif _ = try httpClient.validateResponse(response, data: data) // Store in cache if we have etag and commit info @@ -269,10 +284,14 @@ public extension HubClient { var request = try await httpClient.createRequest(.get, urlPath) request.cachePolicy = cachePolicy - let (tempURL, response) = try await session.download( - for: request, - delegate: progress.map { DownloadProgressDelegate(progress: $0) } - ) + #if canImport(FoundationNetworking) + let (tempURL, response) = try await session.asyncDownload(for: request, progress: progress) + #else + let (tempURL, response) = try await session.download( + for: request, + delegate: progress.map { DownloadProgressDelegate(progress: $0) } + ) + #endif _ = try httpClient.validateResponse(response, data: nil) // Store in cache before moving to destination @@ -305,29 +324,35 @@ public extension HubClient { return destination } - /// Download file with resume capability - /// - Parameters: - /// - resumeData: Resume data from a previous download attempt - /// - destination: Destination URL for downloaded file - /// - progress: Optional Progress object to track download progress - /// - Returns: Final destination URL - func resumeDownloadFile( - resumeData: Data, - to destination: URL, - progress: Progress? = nil - ) async throws -> URL { - let (tempURL, response) = try await session.download( - resumeFrom: resumeData, - delegate: progress.map { DownloadProgressDelegate(progress: $0) } - ) - _ = try httpClient.validateResponse(response, data: nil) + #if !canImport(FoundationNetworking) + /// Download file with resume capability + /// + /// - Note: This method is only available on Apple platforms. + /// On Linux, resume functionality is not supported. + /// + /// - Parameters: + /// - resumeData: Resume data from a previous download attempt + /// - destination: Destination URL for downloaded file + /// - progress: Optional Progress object to track download progress + /// - Returns: Final destination URL + func resumeDownloadFile( + resumeData: Data, + to destination: URL, + progress: Progress? = nil + ) async throws -> URL { + let (tempURL, response) = try await session.download( + resumeFrom: resumeData, + delegate: progress.map { DownloadProgressDelegate(progress: $0) } + ) + _ = try httpClient.validateResponse(response, data: nil) - // Move from temporary location to final destination - try? FileManager.default.removeItem(at: destination) - try FileManager.default.moveItem(at: tempURL, to: destination) + // Move from temporary location to final destination + try? FileManager.default.removeItem(at: destination) + try FileManager.default.moveItem(at: tempURL, to: destination) - return destination - } + return destination + } + #endif /// Download file to a destination URL (convenience method without progress tracking) /// - Parameters: @@ -363,32 +388,35 @@ public extension HubClient { // MARK: - Progress Delegate -private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { - private let progress: Progress +#if !canImport(FoundationNetworking) + private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable + { + private let progress: Progress - init(progress: Progress) { - self.progress = progress - } + init(progress: Progress) { + self.progress = progress + } - func urlSession( - _: URLSession, - downloadTask _: URLSessionDownloadTask, - didWriteData _: Int64, - totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64 - ) { - progress.totalUnitCount = totalBytesExpectedToWrite - progress.completedUnitCount = totalBytesWritten - } + func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + progress.totalUnitCount = totalBytesExpectedToWrite + progress.completedUnitCount = totalBytesWritten + } - func urlSession( - _: URLSession, - downloadTask _: URLSessionDownloadTask, - didFinishDownloadingTo _: URL - ) { - // The actual file handling is done in the async/await layer + func urlSession( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didFinishDownloadingTo _: URL + ) { + // The actual file handling is done in the async/await layer + } } -} +#endif // MARK: - Delete Operations @@ -498,7 +526,11 @@ public extension HubClient { request.setValue("bytes=0-0", forHTTPHeaderField: "Range") do { - let (_, response) = try await session.data(for: request) + #if canImport(FoundationNetworking) + let (_, response) = try await session.asyncData(for: request) + #else + let (_, response) = try await session.data(for: request) + #endif guard let httpResponse = response as? HTTPURLResponse else { return File(exists: false) } @@ -599,9 +631,44 @@ private struct UploadResponse: Codable { private extension URL { var mimeType: String? { - guard let uti = UTType(filenameExtension: pathExtension) else { - return nil - } - return uti.preferredMIMEType + #if canImport(UniformTypeIdentifiers) + guard let uti = UTType(filenameExtension: pathExtension) else { + return nil + } + return uti.preferredMIMEType + #else + // Fallback MIME type lookup for Linux + let ext = pathExtension.lowercased() + switch ext { + case "json": return "application/json" + case "txt": return "text/plain" + case "html", "htm": return "text/html" + case "css": return "text/css" + case "js": return "application/javascript" + case "xml": return "application/xml" + case "pdf": return "application/pdf" + case "zip": return "application/zip" + case "gz", "gzip": return "application/gzip" + case "tar": return "application/x-tar" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "svg": return "image/svg+xml" + case "webp": return "image/webp" + case "mp3": return "audio/mpeg" + case "wav": return "audio/wav" + case "mp4": return "video/mp4" + case "webm": return "video/webm" + case "bin", "safetensors", "gguf", "ggml": return "application/octet-stream" + case "pt", "pth": return "application/octet-stream" + case "onnx": return "application/octet-stream" + case "md": return "text/markdown" + case "yaml", "yml": return "application/x-yaml" + case "toml": return "application/toml" + case "py": return "text/x-python" + case "swift": return "text/x-swift" + default: return "application/octet-stream" + } + #endif } } diff --git a/Sources/HuggingFace/Hub/Pagination.swift b/Sources/HuggingFace/Hub/Pagination.swift index daa591f..c75f3dd 100644 --- a/Sources/HuggingFace/Hub/Pagination.swift +++ b/Sources/HuggingFace/Hub/Pagination.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + /// Sort direction for list queries. public enum SortDirection: Int, Hashable, Sendable { /// Ascending order. @@ -28,38 +32,44 @@ public struct PaginatedResponse: Sendable { } } -// MARK: - +// MARK: - Link Header Parsing -extension HTTPURLResponse { - /// Parses the Link header to extract the next page URL. - /// - /// The Link header format follows RFC 8288: `; rel="next"` - /// - /// - Returns: The URL for the next page, or `nil` if not found. - func nextPageURL() -> URL? { - guard let linkHeader = value(forHTTPHeaderField: "Link") else { - return nil - } +/// Parses the Link header from an HTTP response to extract the next page URL. +/// +/// The Link header format follows RFC 8288: `; rel="next"` +/// +/// - Parameter response: The HTTP response to parse. +/// - Returns: The URL for the next page, or `nil` if not found. +func parseNextPageURL(from response: HTTPURLResponse) -> URL? { + guard let linkHeader = response.value(forHTTPHeaderField: "Link") else { + return nil + } + return parseNextPageURL(fromLinkHeader: linkHeader) +} - // Parse Link header format: ; rel="next" - let links = linkHeader.components(separatedBy: ",") - for link in links { - let components = link.components(separatedBy: ";") - guard components.count >= 2 else { continue } +/// Parses a Link header string to extract the next page URL. +/// +/// - Parameter linkHeader: The Link header value. +/// - Returns: The URL for the next page, or `nil` if not found. +func parseNextPageURL(fromLinkHeader linkHeader: String) -> URL? { + // Parse Link header format: ; rel="next" + let links = linkHeader.components(separatedBy: ",") + for link in links { + let components = link.components(separatedBy: ";") + guard components.count >= 2 else { continue } - let urlPart = components[0].trimmingCharacters(in: .whitespaces) - let relPart = components[1].trimmingCharacters(in: .whitespaces) + let urlPart = components[0].trimmingCharacters(in: .whitespaces) + let relPart = components[1].trimmingCharacters(in: .whitespaces) - // Check if this is the "next" link - if relPart.contains("rel=\"next\"") || relPart.contains("rel='next'") { - // Extract URL from angle brackets - let urlString = urlPart.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) - if let url = URL(string: urlString) { - return url - } + // Check if this is the "next" link + if relPart.contains("rel=\"next\"") || relPart.contains("rel='next'") { + // Extract URL from angle brackets + let urlString = urlPart.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) + if let url = URL(string: urlString) { + return url } } - - return nil } + + return nil } diff --git a/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift b/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift index e194651..9cb5ee0 100644 --- a/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift +++ b/Sources/HuggingFace/OAuth/HuggingFaceAuthenticationManager.swift @@ -486,13 +486,15 @@ // MARK: - -private extension URL { - /// Extracts the OAuth authorization code from a callback URL. - /// - Returns: The authorization code if found, nil otherwise. - var oauthCode: String? { - URLComponents(string: absoluteString)? - .queryItems? - .first(where: { $0.name == "code" })? - .value +#if canImport(AuthenticationServices) + private extension URL { + /// Extracts the OAuth authorization code from a callback URL. + /// - Returns: The authorization code if found, nil otherwise. + var oauthCode: String? { + URLComponents(string: absoluteString)? + .queryItems? + .first(where: { $0.name == "code" })? + .value + } } -} +#endif diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index 1bc7d33..654791e 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -1,10 +1,9 @@ -#if canImport(CryptoKit) - import CryptoKit - import Foundation +import Crypto +import Foundation - #if canImport(FoundationNetworking) - import FoundationNetworking - #endif // canImport(FoundationNetworking) +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif /// An OAuth 2.0 client for handling authentication flows /// with support for token caching, refresh, and secure code exchange @@ -121,7 +120,11 @@ ] request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - let (data, response) = try await urlSession.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await urlSession.asyncData(for: request) + #else + let (data, response) = try await urlSession.data(for: request) + #endif guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) @@ -129,9 +132,7 @@ throw OAuthError.tokenExchangeFailed } - let tokenResponse = try await MainActor.run { - try JSONDecoder().decode(TokenResponse.self, from: data) - } + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) let token = OAuthToken( accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken, @@ -189,7 +190,11 @@ ] request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - let (data, response) = try await urlSession.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await urlSession.asyncData(for: request) + #else + let (data, response) = try await urlSession.data(for: request) + #endif guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) @@ -197,9 +202,7 @@ throw OAuthError.tokenExchangeFailed } - let tokenResponse = try await MainActor.run { - try JSONDecoder().decode(TokenResponse.self, from: data) - } + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) let token = OAuthToken( accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken ?? refreshToken, @@ -213,9 +216,12 @@ /// Generates PKCE code verifier and challenge values as a tuple. /// - Returns: A tuple containing the code verifier and its corresponding challenge. private static func generatePKCEValues() -> (verifier: String, challenge: String) { - // Generate a cryptographically secure random code verifier + // Generate a cryptographically secure random code verifier using swift-crypto's + // cross-platform secure random generation var buffer = [UInt8](repeating: 0, count: 32) - _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + for i in 0.. String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } +// MARK: - + +private extension Data { + /// Returns a URL-safe Base64 encoded string suitable for use in URLs and OAuth flows. + /// + /// This method applies the standard Base64 encoding and then replaces characters + /// that are not URL-safe (+ becomes -, / becomes _, = padding is removed). + /// - Returns: A URL-safe Base64 encoded string. + func urlSafeBase64EncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") } -#endif // canImport(CryptoKit) +} diff --git a/Sources/HuggingFace/OAuth/TokenStorage.swift b/Sources/HuggingFace/OAuth/TokenStorage.swift new file mode 100644 index 0000000..b7a1b5e --- /dev/null +++ b/Sources/HuggingFace/OAuth/TokenStorage.swift @@ -0,0 +1,194 @@ +import Foundation + +/// A cross-platform mechanism for storing and retrieving OAuth tokens. +/// +/// This provides a file-based storage implementation that works on all platforms, +/// including Linux. For Apple platforms, the `HuggingFaceAuthenticationManager` +/// provides keychain-based storage through its own `TokenStorage` type. +/// +/// Example usage: +/// ```swift +/// let storage = FileTokenStorage.default +/// try storage.store(token) +/// let retrieved = try storage.retrieve() +/// ``` +public struct FileTokenStorage: Sendable { + private let fileURL: URL + + /// Creates a new file-based token storage at the specified URL. + /// - Parameter fileURL: The URL where tokens will be stored. + public init(fileURL: URL) { + self.fileURL = fileURL + } + + /// The default token storage location. + /// + /// On Linux/Unix: `~/.cache/huggingface/token.json` + /// On macOS: `~/Library/Caches/huggingface/token.json` + public static var `default`: FileTokenStorage { + let cacheDir: URL + #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + cacheDir = + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + #else + // Linux/Unix: Use XDG_CACHE_HOME or ~/.cache + if let xdgCache = ProcessInfo.processInfo.environment["XDG_CACHE_HOME"] { + cacheDir = URL(fileURLWithPath: xdgCache) + } else { + let home = + ProcessInfo.processInfo.environment["HOME"] + ?? NSHomeDirectory() + cacheDir = URL(fileURLWithPath: home).appendingPathComponent(".cache") + } + #endif + + let tokenDir = cacheDir.appendingPathComponent("huggingface") + let tokenFile = tokenDir.appendingPathComponent("token.json") + + return FileTokenStorage(fileURL: tokenFile) + } + + /// Stores an OAuth token to the file. + /// - Parameter token: The token to store. + /// - Throws: An error if the token cannot be encoded or written. + public func store(_ token: OAuthToken) throws { + // Create directory if needed + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + // Encode and write + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(token) + try data.write(to: fileURL, options: .atomic) + + // Set file permissions to owner-only (0600) on Unix systems + #if os(Linux) || os(macOS) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: fileURL.path + ) + #endif + } + + /// Retrieves the stored OAuth token. + /// - Returns: The stored token, or `nil` if no token is stored. + /// - Throws: An error if the token file exists but cannot be read or decoded. + public func retrieve() throws -> OAuthToken? { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + + let data = try Data(contentsOf: fileURL) + let decoder = JSONDecoder() + return try decoder.decode(OAuthToken.self, from: data) + } + + /// Deletes the stored OAuth token. + /// - Throws: An error if the token file exists but cannot be deleted. + public func delete() throws { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + try FileManager.default.removeItem(at: fileURL) + } + + /// Whether a token is currently stored. + public var hasStoredToken: Bool { + FileManager.default.fileExists(atPath: fileURL.path) + } +} + +// MARK: - Environment Token Storage + +/// A simple token storage that reads from an environment variable. +/// +/// This is useful for server-side applications and CI/CD environments +/// where tokens are provided via environment variables. +public struct EnvironmentTokenStorage: Sendable { + private let variableName: String + + /// Creates a new environment token storage. + /// - Parameter variableName: The name of the environment variable containing the token. + /// Defaults to `HF_TOKEN`. + public init(variableName: String = "HF_TOKEN") { + self.variableName = variableName + } + + /// Retrieves the token from the environment variable. + /// - Returns: An OAuth token with the access token from the environment, or `nil` if not set. + public func retrieve() -> OAuthToken? { + guard let token = ProcessInfo.processInfo.environment[variableName], + !token.isEmpty + else { + return nil + } + + // Environment tokens don't expire and don't have refresh tokens + return OAuthToken( + accessToken: token, + refreshToken: nil, + expiresAt: Date.distantFuture + ) + } +} + +// MARK: - Composite Token Storage + +/// A token storage that tries multiple storage backends in order. +/// +/// This is useful for applications that want to support multiple token sources, +/// such as checking environment variables first, then falling back to file storage. +public struct CompositeTokenStorage: Sendable { + private let storages: [@Sendable () throws -> OAuthToken?] + private let primaryStorage: FileTokenStorage? + + /// Creates a composite token storage with the specified backends. + /// - Parameters: + /// - environment: Whether to check environment variables first. + /// - file: The file storage to use, or `nil` to skip file storage. + public init( + environment: Bool = true, + file: FileTokenStorage? = .default + ) { + var storages: [@Sendable () throws -> OAuthToken?] = [] + + if environment { + let envStorage = EnvironmentTokenStorage() + storages.append { envStorage.retrieve() } + } + + if let file = file { + storages.append { try file.retrieve() } + } + + self.storages = storages + self.primaryStorage = file + } + + /// Retrieves a token from the first storage that has one. + /// - Returns: The first available token, or `nil` if none found. + public func retrieve() throws -> OAuthToken? { + for storage in storages { + if let token = try storage() { + return token + } + } + return nil + } + + /// Stores a token to the primary (file) storage. + /// - Parameter token: The token to store. + public func store(_ token: OAuthToken) throws { + try primaryStorage?.store(token) + } + + /// Deletes the token from the primary (file) storage. + public func delete() throws { + try primaryStorage?.delete() + } +} diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index 66801f2..e63327f 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -72,7 +72,11 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> T { let request = try await createRequest(method, path, params: params, headers: headers) - let (data, response) = try await session.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncData(for: request) + #else + let (data, response) = try await session.data(for: request) + #endif let httpResponse = try validateResponse(response, data: data) if T.self == Bool.self { @@ -99,12 +103,16 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> PaginatedResponse { let request = try await createRequest(method, path, params: params, headers: headers) - let (data, response) = try await session.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncData(for: request) + #else + let (data, response) = try await session.data(for: request) + #endif let httpResponse = try validateResponse(response, data: data) do { let items = try jsonDecoder.decode([T].self, from: data) - let nextURL = httpResponse.nextPageURL() + let nextURL = parseNextPageURL(from: httpResponse) return PaginatedResponse(items: items, nextURL: nextURL) } catch { throw HTTPClientError.decodingError( @@ -124,41 +132,86 @@ final class HTTPClient: @unchecked Sendable { let task = Task { do { let request = try await createRequest(method, path, params: params, headers: headers) - let (bytes, response) = try await session.bytes(for: request) - let httpResponse = try validateResponse(response) - guard (200 ..< 300).contains(httpResponse.statusCode) else { - var errorData = Data() - for try await byte in bytes { - errorData.append(byte) + #if canImport(FoundationNetworking) + // Linux: Use buffered approach since true streaming is not available + let (data, response) = try await session.asyncData(for: request) + let httpResponse = try validateResponse(response, data: data) + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + return } - // validateResponse will throw the appropriate error - _ = try validateResponse(response, data: errorData) - return // This line will never be reached, but satisfies the compiler - } - - for try await event in bytes.events { - // Check for [DONE] signal - if event.data.trimmingCharacters(in: .whitespacesAndNewlines) == "[DONE]" { + + // Parse SSE events from the buffered response + guard let responseString = String(data: data, encoding: .utf8) else { continuation.finish() return } - guard let jsonData = event.data.data(using: .utf8) else { - continue + for line in responseString.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("data:") else { continue } + + let eventData = String(trimmed.dropFirst(5)).trimmingCharacters( + in: .whitespaces) + + // Check for [DONE] signal + if eventData == "[DONE]" { + continuation.finish() + return + } + + guard let jsonData = eventData.data(using: .utf8) else { + continue + } + + do { + let decoded = try jsonDecoder.decode(T.self, from: jsonData) + continuation.yield(decoded) + } catch { + print("Warning: Failed to decode streaming response chunk: \(error)") + } + } + + continuation.finish() + #else + // Apple platforms: Use native streaming APIs + let (bytes, response) = try await session.bytes(for: request) + let httpResponse = try validateResponse(response) + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + var errorData = Data() + for try await byte in bytes { + errorData.append(byte) + } + // validateResponse will throw the appropriate error + _ = try validateResponse(response, data: errorData) + return // This line will never be reached, but satisfies the compiler } - do { - let decoded = try jsonDecoder.decode(T.self, from: jsonData) - continuation.yield(decoded) - } catch { - // Log decoding errors but don't fail the stream - // This allows the stream to continue even if individual chunks fail - print("Warning: Failed to decode streaming response chunk: \(error)") + for try await event in bytes.events { + // Check for [DONE] signal + if event.data.trimmingCharacters(in: .whitespacesAndNewlines) == "[DONE]" { + continuation.finish() + return + } + + guard let jsonData = event.data.data(using: .utf8) else { + continue + } + + do { + let decoded = try jsonDecoder.decode(T.self, from: jsonData) + continuation.yield(decoded) + } catch { + // Log decoding errors but don't fail the stream + // This allows the stream to continue even if individual chunks fail + print("Warning: Failed to decode streaming response chunk: \(error)") + } } - } - continuation.finish() + continuation.finish() + #endif } catch { continuation.finish(throwing: error) } @@ -177,7 +230,11 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> Data { let request = try await createRequest(method, path, params: params, headers: headers) - let (data, response) = try await session.data(for: request) + #if canImport(FoundationNetworking) + let (data, response) = try await session.asyncData(for: request) + #else + let (data, response) = try await session.data(for: request) + #endif let _ = try validateResponse(response, data: data) return data diff --git a/Sources/HuggingFace/Shared/URLSession+Linux.swift b/Sources/HuggingFace/Shared/URLSession+Linux.swift new file mode 100644 index 0000000..ec573b0 --- /dev/null +++ b/Sources/HuggingFace/Shared/URLSession+Linux.swift @@ -0,0 +1,237 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking + + // MARK: - URLSession Async Extensions for Linux + + /// Provides async/await wrappers for URLSession APIs that are missing on Linux. + /// These extensions bridge the callback-based APIs to Swift's concurrency model. + extension URLSession { + /// Performs an HTTP request and returns the response data. + /// + /// This is a compatibility shim for Linux where the native async `data(for:)` may not be available. + /// + /// - Parameter request: The URL request to perform. + /// - Returns: A tuple containing the response data and URL response. + func asyncData(for request: URLRequest) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = self.dataTask(with: request) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume( + throwing: URLError(.badServerResponse) + ) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + } + + /// Uploads data to a URL and returns the response. + /// + /// - Parameters: + /// - request: The URL request to perform. + /// - data: The data to upload. + /// - Returns: A tuple containing the response data and URL response. + func asyncUpload(for request: URLRequest, from data: Data) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = self.uploadTask(with: request, from: data) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume( + throwing: URLError(.badServerResponse) + ) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + } + + /// Uploads a file to a URL and returns the response. + /// + /// - Parameters: + /// - request: The URL request to perform. + /// - fileURL: The URL of the file to upload. + /// - Returns: A tuple containing the response data and URL response. + func asyncUpload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) + { + try await withCheckedThrowingContinuation { continuation in + let task = self.uploadTask(with: request, fromFile: fileURL) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume( + throwing: URLError(.badServerResponse) + ) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + } + + /// Downloads a file from a URL to a temporary location. + /// + /// - Parameters: + /// - request: The URL request to perform. + /// - progress: Optional progress object to track download progress. + /// - Returns: A tuple containing the temporary file URL and URL response. + func asyncDownload( + for request: URLRequest, + progress: Progress? = nil + ) async throws -> (URL, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let delegate = progress.map { LinuxDownloadDelegate(progress: $0, continuation: continuation) } + + if let delegate = delegate { + // Use delegate-based download for progress tracking + let session = URLSession( + configuration: self.configuration, + delegate: delegate, + delegateQueue: nil + ) + let task = session.downloadTask(with: request) + delegate.task = task + task.resume() + } else { + // Simple download without progress + let task = self.downloadTask(with: request) { url, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let tempURL = url, let response = response else { + continuation.resume(throwing: URLError(.badServerResponse)) + return + } + // Copy to a new temp location since the original will be deleted + let newTempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + do { + try FileManager.default.copyItem(at: tempURL, to: newTempURL) + continuation.resume(returning: (newTempURL, response)) + } catch { + continuation.resume(throwing: error) + } + } + task.resume() + } + } + } + + /// Streams bytes from a URL request. + /// + /// This provides a simplified streaming interface for Linux where `bytes(for:)` is not available. + /// Note: This implementation buffers the entire response, so it's not suitable for very large responses. + /// For true streaming on Linux, consider using a different HTTP client library. + /// + /// - Parameter request: The URL request to perform. + /// - Returns: A tuple containing the response bytes and URL response. + func asyncBytes(for request: URLRequest) async throws -> (LinuxAsyncBytes, URLResponse) { + let (data, response) = try await asyncData(for: request) + return (LinuxAsyncBytes(data: data), response) + } + } + + // MARK: - Linux Download Delegate + + /// A delegate for tracking download progress on Linux. + private final class LinuxDownloadDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + let progress: Progress + let continuation: CheckedContinuation<(URL, URLResponse), Error> + var task: URLSessionDownloadTask? + private var hasResumed = false + + init(progress: Progress, continuation: CheckedContinuation<(URL, URLResponse), Error>) { + self.progress = progress + self.continuation = continuation + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + progress.totalUnitCount = totalBytesExpectedToWrite + progress.completedUnitCount = totalBytesWritten + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + guard !hasResumed else { return } + hasResumed = true + + guard let response = downloadTask.response else { + continuation.resume(throwing: URLError(.badServerResponse)) + return + } + + // Copy to a new temp location since the original will be deleted + let newTempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + do { + try FileManager.default.copyItem(at: location, to: newTempURL) + continuation.resume(returning: (newTempURL, response)) + } catch { + continuation.resume(throwing: error) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard !hasResumed else { return } + hasResumed = true + + if let error = error { + continuation.resume(throwing: error) + } + // If no error and not resumed, the download delegate method should have handled it + } + } + + // MARK: - Linux Async Bytes + + /// A simple async sequence wrapper for bytes on Linux. + /// This is a simplified implementation that works with pre-loaded data. + struct LinuxAsyncBytes: AsyncSequence, Sendable { + typealias Element = UInt8 + + let data: Data + + struct AsyncIterator: AsyncIteratorProtocol { + var index: Data.Index + let endIndex: Data.Index + let data: Data + + mutating func next() async -> UInt8? { + guard index < endIndex else { return nil } + let byte = data[index] + index = data.index(after: index) + return byte + } + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(index: data.startIndex, endIndex: data.endIndex, data: data) + } + } + +#endif diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index 64afc1b..0b0775a 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace // MARK: - Request Handler Storage @@ -52,11 +56,6 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { await requestHandlerStorage.setHandler(handler) } - /// Execute the stored handler for a request - func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) { - return try await Self.requestHandlerStorage.executeHandler(for: request) - } - override class func canInit(with request: URLRequest) -> Bool { return true } @@ -66,14 +65,23 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { } override func startLoading() { + // Capture values before entering the Task to avoid data races. + // We use nonisolated(unsafe) because URLProtocol is inherently non-Sendable, + // but we know the URLSession will keep this instance alive during loading. + let capturedRequest = request + nonisolated(unsafe) let capturedClient = client + nonisolated(unsafe) let capturedSelf = self + Task { do { - let (response, data) = try await self.executeHandler(for: request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) + let (response, data) = try await Self.requestHandlerStorage.executeHandler( + for: capturedRequest) + capturedClient?.urlProtocol( + capturedSelf, didReceive: response, cacheStoragePolicy: .notAllowed) + capturedClient?.urlProtocol(capturedSelf, didLoad: data) + capturedClient?.urlProtocolDidFinishLoading(capturedSelf) } catch { - client?.urlProtocol(self, didFailWithError: error) + capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) } } } diff --git a/Tests/HuggingFaceTests/HubTests/CacheLocationProviderTests.swift b/Tests/HuggingFaceTests/HubTests/CacheLocationProviderTests.swift index 8277ad9..465f85d 100644 --- a/Tests/HuggingFaceTests/HubTests/CacheLocationProviderTests.swift +++ b/Tests/HuggingFaceTests/HubTests/CacheLocationProviderTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/CollectionTests.swift b/Tests/HuggingFaceTests/HubTests/CollectionTests.swift index 20b5bb4..db9cf5c 100644 --- a/Tests/HuggingFaceTests/HubTests/CollectionTests.swift +++ b/Tests/HuggingFaceTests/HubTests/CollectionTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift index 3a6c93c..5b0d505 100644 --- a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift +++ b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift b/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift index 6c62ba6..389e99d 100644 --- a/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift +++ b/Tests/HuggingFaceTests/HubTests/DiscussionTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/FileLockTests.swift b/Tests/HuggingFaceTests/HubTests/FileLockTests.swift index 29ac262..5af6cf7 100644 --- a/Tests/HuggingFaceTests/HubTests/FileLockTests.swift +++ b/Tests/HuggingFaceTests/HubTests/FileLockTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift index d5d4150..81fc688 100644 --- a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift +++ b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/GatedModeTests.swift b/Tests/HuggingFaceTests/HubTests/GatedModeTests.swift index 5d6e412..94bd500 100644 --- a/Tests/HuggingFaceTests/HubTests/GatedModeTests.swift +++ b/Tests/HuggingFaceTests/HubTests/GatedModeTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/GitTests.swift b/Tests/HuggingFaceTests/HubTests/GitTests.swift index a273461..276b1a8 100644 --- a/Tests/HuggingFaceTests/HubTests/GitTests.swift +++ b/Tests/HuggingFaceTests/HubTests/GitTests.swift @@ -1,5 +1,9 @@ import Testing import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif @testable import HuggingFace @Suite("Git Tests") diff --git a/Tests/HuggingFaceTests/HubTests/HubCacheTests.swift b/Tests/HuggingFaceTests/HubTests/HubCacheTests.swift index a8aad4a..8380159 100644 --- a/Tests/HuggingFaceTests/HubTests/HubCacheTests.swift +++ b/Tests/HuggingFaceTests/HubTests/HubCacheTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/HubClientTests.swift b/Tests/HuggingFaceTests/HubTests/HubClientTests.swift index b691ac4..8d55afd 100644 --- a/Tests/HuggingFaceTests/HubTests/HubClientTests.swift +++ b/Tests/HuggingFaceTests/HubTests/HubClientTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/ModelTests.swift b/Tests/HuggingFaceTests/HubTests/ModelTests.swift index b047ef4..38c36d2 100644 --- a/Tests/HuggingFaceTests/HubTests/ModelTests.swift +++ b/Tests/HuggingFaceTests/HubTests/ModelTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift index 873b865..6bf92d9 100644 --- a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift +++ b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/PaginationTests.swift b/Tests/HuggingFaceTests/HubTests/PaginationTests.swift index 206199b..ccb1bbc 100644 --- a/Tests/HuggingFaceTests/HubTests/PaginationTests.swift +++ b/Tests/HuggingFaceTests/HubTests/PaginationTests.swift @@ -37,7 +37,7 @@ struct PaginationTests { linkHeader: "; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect(nextURL?.absoluteString == "https://huggingface.co/api/models?limit=10&skip=10") @@ -49,7 +49,7 @@ struct PaginationTests { linkHeader: "; rel='next'" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect(nextURL?.absoluteString == "https://huggingface.co/api/page2") @@ -62,7 +62,7 @@ struct PaginationTests { "; rel=\"prev\", ; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect(nextURL?.absoluteString == "https://huggingface.co/api/page3") @@ -74,7 +74,7 @@ struct PaginationTests { linkHeader: " ; rel=\"next\" " ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect(nextURL?.absoluteString == "https://huggingface.co/api/page2") @@ -87,7 +87,7 @@ struct PaginationTests { "; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect( @@ -100,7 +100,7 @@ struct PaginationTests { func testMissingLinkHeader() { let response = makeHTTPResponse(linkHeader: nil) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL == nil) } @@ -109,7 +109,7 @@ struct PaginationTests { func testEmptyLinkHeader() { let response = makeHTTPResponse(linkHeader: "") - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL == nil) } @@ -120,7 +120,7 @@ struct PaginationTests { linkHeader: "; rel=\"prev\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL == nil) } @@ -131,7 +131,7 @@ struct PaginationTests { linkHeader: "https://huggingface.co/api/page2; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) // Should still extract the URL even without proper angle brackets #expect(nextURL != nil) @@ -143,7 +143,7 @@ struct PaginationTests { linkHeader: "<>; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL == nil) } @@ -154,7 +154,7 @@ struct PaginationTests { linkHeader: " rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL == nil) } @@ -165,7 +165,7 @@ struct PaginationTests { linkHeader: "; rel=\"next\"; title=\"Next Page\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) #expect(nextURL?.absoluteString == "https://huggingface.co/api/page2") @@ -178,7 +178,7 @@ struct PaginationTests { "; rel=\"next\", ; rel=\"next\"" ) - let nextURL = response.nextPageURL() + let nextURL = parseNextPageURL(from: response) #expect(nextURL != nil) // Should return the first "next" link found diff --git a/Tests/HuggingFaceTests/HubTests/PaperTests.swift b/Tests/HuggingFaceTests/HubTests/PaperTests.swift index 6d4aba7..e1145cf 100644 --- a/Tests/HuggingFaceTests/HubTests/PaperTests.swift +++ b/Tests/HuggingFaceTests/HubTests/PaperTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/RepoIDTests.swift b/Tests/HuggingFaceTests/HubTests/RepoIDTests.swift index b1395b0..62c45fb 100644 --- a/Tests/HuggingFaceTests/HubTests/RepoIDTests.swift +++ b/Tests/HuggingFaceTests/HubTests/RepoIDTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/RepoTests.swift b/Tests/HuggingFaceTests/HubTests/RepoTests.swift index 8f30e97..599ee82 100644 --- a/Tests/HuggingFaceTests/HubTests/RepoTests.swift +++ b/Tests/HuggingFaceTests/HubTests/RepoTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/SpaceTests.swift b/Tests/HuggingFaceTests/HubTests/SpaceTests.swift index 5124dba..6399de6 100644 --- a/Tests/HuggingFaceTests/HubTests/SpaceTests.swift +++ b/Tests/HuggingFaceTests/HubTests/SpaceTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/HubTests/UserTests.swift b/Tests/HuggingFaceTests/HubTests/UserTests.swift index 8a63f4f..ca24e9d 100644 --- a/Tests/HuggingFaceTests/HubTests/UserTests.swift +++ b/Tests/HuggingFaceTests/HubTests/UserTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift index 92066b3..713ed78 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift index 937c015..c645cd3 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/FeatureExtractionTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift index d42fd69..78d7797 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift index dab10d7..fa00576 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift index 9cf856f..4220e3e 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift @@ -1,6 +1,10 @@ import Foundation import Testing +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift index 98c42e0..489a140 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift b/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift index 2db58ad..1b2cc07 100644 --- a/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift +++ b/Tests/HuggingFaceTests/OAuthTests/HuggingFaceAuthenticationManagerTests.swift @@ -1,9 +1,15 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace -#if swift(>=6.1) +// HuggingFaceAuthenticationManager tests are only available on Apple platforms +// since the manager uses AuthenticationServices which is not available on Linux. +#if canImport(AuthenticationServices) && swift(>=6.1) @Suite("HuggingFace Authentication Manager Tests") struct HuggingFaceAuthenticationManagerTests { @Test("HuggingFaceAuthenticationManager can be initialized with valid parameters") @@ -185,4 +191,4 @@ import Testing #expect(customScope == .other("custom-scope")) } } -#endif // swift(>=6.1) +#endif // canImport(AuthenticationServices) && swift(>=6.1) diff --git a/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift b/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift index 131b792..e70b2e8 100644 --- a/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift +++ b/Tests/HuggingFaceTests/OAuthTests/OAuthClientTests.swift @@ -1,4 +1,8 @@ import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif import Testing @testable import HuggingFace From 05cbc022f272b9c3f378c6630f94369e27facd8e Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Tue, 9 Dec 2025 10:24:40 +0100 Subject: [PATCH 10/37] Set swift version to 6.1 now to make everything compile. --- .swift-version | 1 + .../HuggingFaceTests/Helpers/MockURLProtocol.swift | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 .swift-version diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..d7ff925 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.1.3 \ No newline at end of file diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index 0b0775a..fdcb272 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -75,13 +75,14 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { Task { do { let (response, data) = try await Self.requestHandlerStorage.executeHandler( - for: capturedRequest) - capturedClient?.urlProtocol( - capturedSelf, didReceive: response, cacheStoragePolicy: .notAllowed) - capturedClient?.urlProtocol(capturedSelf, didLoad: data) - capturedClient?.urlProtocolDidFinishLoading(capturedSelf) + for: capturedRequest + ) + /* capturedClient?.urlProtocol( + capturedSelf, didReceive: response, cacheStoragePolicy: .notAllowed) + capturedClient?.urlProtocol(capturedSelf, didLoad: data) + capturedClient?.urlProtocolDidFinishLoading(capturedSelf)*/ } catch { - capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) + //capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) } } } From d8b4b27a47c77f3432da596afd32e1482b9aa471 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Tue, 9 Dec 2025 11:30:58 +0100 Subject: [PATCH 11/37] Removed Streaming.swift as it wasn't used any more. --- Sources/HuggingFace/Shared/Streaming.swift | 102 --------------------- 1 file changed, 102 deletions(-) delete mode 100644 Sources/HuggingFace/Shared/Streaming.swift diff --git a/Sources/HuggingFace/Shared/Streaming.swift b/Sources/HuggingFace/Shared/Streaming.swift deleted file mode 100644 index 9a0e900..0000000 --- a/Sources/HuggingFace/Shared/Streaming.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import EventSource - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -final class StreamingDelegate: NSObject, URLSessionDataDelegate { - private let continuation: AsyncThrowingStream.Continuation - private let parser = EventSource.Parser() - private let onResponse: @Sendable (HTTPURLResponse) throws -> Void - - init( - continuation: AsyncThrowingStream.Continuation, - onResponse: @escaping @Sendable (HTTPURLResponse) throws -> Void - ) { - self.continuation = continuation - self.onResponse = onResponse - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse, - completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void - ) { - guard - let http = response - as? HTTPURLResponse /*, - (200 ..< 300).contains(http.statusCode), - http.value(forHTTPHeaderField: "Content-Type")?.lowercased().hasPrefix("text/event-stream") == true*/ - else { - completionHandler(.cancel) - continuation.finish(throwing: URLError(.badServerResponse)) - return - } - - do { - try onResponse(http) - completionHandler(.allow) - } catch { - completionHandler(.cancel) - continuation.finish(throwing: error) - } - } - - func urlSession( - _ session: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - Task { - for byte in data { - await parser.consume(byte) - while let event = await parser.getNextEvent() { - continuation.yield(event) - } - } - } - } - - func urlSession( - _ session: URLSession, - task: URLSessionTask, - didCompleteWithError error: Error? - ) { - Task { - await parser.finish() - if let event = await parser.getNextEvent() { - continuation.yield(event) - } - if let error { continuation.finish(throwing: error) } else { continuation.finish() } - } - } -} - -func streamEvents( - request: URLRequest, - configuration: URLSessionConfiguration = .default, - onResponse: @escaping @Sendable (HTTPURLResponse) throws -> Void -) - -> AsyncThrowingStream -{ - AsyncThrowingStream { continuation in - let delegate = StreamingDelegate( - continuation: continuation, - onResponse: onResponse - ) - let session = URLSession( - configuration: configuration, - delegate: delegate, - delegateQueue: nil - ) - let task = session.dataTask(with: request) - task.resume() - - continuation.onTermination = { _ in - task.cancel() - session.invalidateAndCancel() - } - } -} From d9258fbac7461b3eb08225668d4a760ec31f694f Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Tue, 9 Dec 2025 12:23:38 +0100 Subject: [PATCH 12/37] Fixed tests running slowly. Forgot to uncomment some code --- .../HuggingFaceTests/Helpers/MockURLProtocol.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index fdcb272..941adb6 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -77,12 +77,15 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { let (response, data) = try await Self.requestHandlerStorage.executeHandler( for: capturedRequest ) - /* capturedClient?.urlProtocol( - capturedSelf, didReceive: response, cacheStoragePolicy: .notAllowed) - capturedClient?.urlProtocol(capturedSelf, didLoad: data) - capturedClient?.urlProtocolDidFinishLoading(capturedSelf)*/ + capturedClient?.urlProtocol( + capturedSelf, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + capturedClient?.urlProtocol(capturedSelf, didLoad: data) + capturedClient?.urlProtocolDidFinishLoading(capturedSelf) } catch { - //capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) + capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) } } } From 6b1bcdc88630c5190e94366f6ded7d8c0cad662b Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Thu, 11 Dec 2025 11:43:21 +0100 Subject: [PATCH 13/37] We need asyncData only when streaming --- Sources/HuggingFace/Shared/HTTPClient.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index a92f270..e1a258f 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -87,11 +87,7 @@ final class HTTPClient: @unchecked Sendable { } private func performFetch(request: URLRequest) async throws -> T { - #if canImport(FoundationNetworking) - let (data, response) = try await session.asyncData(for: request) - #else - let (data, response) = try await session.data(for: request) - #endif + let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) if T.self == Bool.self { @@ -118,11 +114,7 @@ final class HTTPClient: @unchecked Sendable { headers: [String: String]? = nil ) async throws -> PaginatedResponse { let request = try await createRequest(method, path, params: params, headers: headers) - #if canImport(FoundationNetworking) - let (data, response) = try await session.asyncData(for: request) - #else - let (data, response) = try await session.data(for: request) - #endif + let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) do { From 2478546ac155cbe4295ff13bbe868209875f59e7 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Thu, 11 Dec 2025 13:23:31 +0100 Subject: [PATCH 14/37] Added a bit of debugging to HTTPCLient. --- Sources/HuggingFace/Shared/HTTPClient.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index e1a258f..d71eaaa 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -90,6 +90,13 @@ final class HTTPClient: @unchecked Sendable { let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) + // Debug: print raw response body to aid troubleshooting + if let bodyString = String(data: data, encoding: .utf8) { + print("HTTPClient response \(httpResponse.statusCode): \(bodyString)") + } else { + print("HTTPClient response \(httpResponse.statusCode): <\(data.count) bytes; non-UTF8>") + } + if T.self == Bool.self { // If T is Bool, we return true for successful response return true as! T From 8e9b4ed534ff2fa426a6ca6eb439a8607c54fce7 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Thu, 11 Dec 2025 13:34:18 +0100 Subject: [PATCH 15/37] Ignore gated for now --- Sources/HuggingFace/Hub/Dataset.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/HuggingFace/Hub/Dataset.swift b/Sources/HuggingFace/Hub/Dataset.swift index 726a7e5..2577351 100644 --- a/Sources/HuggingFace/Hub/Dataset.swift +++ b/Sources/HuggingFace/Hub/Dataset.swift @@ -18,7 +18,7 @@ public struct Dataset: Identifiable, Codable, Sendable { public let visibility: Repo.Visibility? /// Whether the dataset is gated. - public let gated: GatedMode? + //public let gated: GatedMode? /// Whether the dataset is disabled. public let isDisabled: Bool? @@ -57,7 +57,7 @@ public struct Dataset: Identifiable, Codable, Sendable { case sha case lastModified case visibility = "private" - case gated + //case gated case isDisabled = "disabled" case downloads case likes From 9e4033bb830b625752b26941a956a8325b9b2082 Mon Sep 17 00:00:00 2001 From: Tim De Jong Date: Thu, 11 Dec 2025 13:37:24 +0100 Subject: [PATCH 16/37] Dataset parsing should be ok --- Sources/HuggingFace/Hub/Dataset.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/HuggingFace/Hub/Dataset.swift b/Sources/HuggingFace/Hub/Dataset.swift index 2577351..726a7e5 100644 --- a/Sources/HuggingFace/Hub/Dataset.swift +++ b/Sources/HuggingFace/Hub/Dataset.swift @@ -18,7 +18,7 @@ public struct Dataset: Identifiable, Codable, Sendable { public let visibility: Repo.Visibility? /// Whether the dataset is gated. - //public let gated: GatedMode? + public let gated: GatedMode? /// Whether the dataset is disabled. public let isDisabled: Bool? @@ -57,7 +57,7 @@ public struct Dataset: Identifiable, Codable, Sendable { case sha case lastModified case visibility = "private" - //case gated + case gated case isDisabled = "disabled" case downloads case likes From 5a3c0e3b7d9ba8882f3b4942ef2db79ee9e7caac Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:40:40 -0800 Subject: [PATCH 17/37] Delete .vscode --- .vscode/settings.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ff18881..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:pyenv", - "python-envs.pythonProjects": [] -} \ No newline at end of file From 61ddcfa64e5036516ec91ad4aecf1bb3ce23ee21 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:44:29 -0800 Subject: [PATCH 18/37] Delete .swift-version --- .swift-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .swift-version diff --git a/.swift-version b/.swift-version deleted file mode 100644 index d7ff925..0000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -6.1.3 \ No newline at end of file From ea6f75baa08eab5faa01e7195cb948514af552d7 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:58:48 -0800 Subject: [PATCH 19/37] Rename asyncUpload to upload --- Sources/HuggingFace/Hub/HubClient+Files.swift | 12 ++---------- Sources/HuggingFace/Shared/URLSession+Linux.swift | 4 ++-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index 0c13428..bf52e32 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -81,11 +81,7 @@ public extension HubClient { .buildToTempFile() defer { try? FileManager.default.removeItem(at: tempFile) } - #if canImport(FoundationNetworking) - let (data, response) = try await session.asyncUpload(for: request, fromFile: tempFile) - #else - let (data, response) = try await session.upload(for: request, fromFile: tempFile) - #endif + let (data, response) = try await session.upload(for: request, fromFile: tempFile) _ = try httpClient.validateResponse(response, data: data) if data.isEmpty { @@ -102,11 +98,7 @@ public extension HubClient { .addFile(name: "file", fileURL: fileURL, mimeType: mimeType) .buildInMemory() - #if canImport(FoundationNetworking) - let (data, response) = try await session.asyncUpload(for: request, from: body) - #else - let (data, response) = try await session.upload(for: request, from: body) - #endif + let (data, response) = try await session.upload(for: request, from: body) _ = try httpClient.validateResponse(response, data: data) if data.isEmpty { diff --git a/Sources/HuggingFace/Shared/URLSession+Linux.swift b/Sources/HuggingFace/Shared/URLSession+Linux.swift index ec573b0..359856b 100644 --- a/Sources/HuggingFace/Shared/URLSession+Linux.swift +++ b/Sources/HuggingFace/Shared/URLSession+Linux.swift @@ -39,7 +39,7 @@ import Foundation /// - request: The URL request to perform. /// - data: The data to upload. /// - Returns: A tuple containing the response data and URL response. - func asyncUpload(for request: URLRequest, from data: Data) async throws -> (Data, URLResponse) { + func upload(for request: URLRequest, from data: Data) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let task = self.uploadTask(with: request, from: data) { data, response, error in if let error = error { @@ -64,7 +64,7 @@ import Foundation /// - request: The URL request to perform. /// - fileURL: The URL of the file to upload. /// - Returns: A tuple containing the response data and URL response. - func asyncUpload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let task = self.uploadTask(with: request, fromFile: fileURL) { data, response, error in From 83368d5fdefb545ca83b81281ef510390573bcb5 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:58:56 -0800 Subject: [PATCH 20/37] Remove duplicate import statement --- Sources/HuggingFace/Hub/Pagination.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/HuggingFace/Hub/Pagination.swift b/Sources/HuggingFace/Hub/Pagination.swift index 7489179..412ec8f 100644 --- a/Sources/HuggingFace/Hub/Pagination.swift +++ b/Sources/HuggingFace/Hub/Pagination.swift @@ -3,10 +3,6 @@ import Foundation import FoundationNetworking #endif -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - /// Sort direction for list queries. public enum SortDirection: Int, Hashable, Sendable { /// Ascending order. From d0d888b0ff14e50763905a6bc7cc08386a6fc3fd Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:59:23 -0800 Subject: [PATCH 21/37] Rename parseNextPageURL to consistently use from: label with type overload --- Sources/HuggingFace/Hub/Pagination.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/HuggingFace/Hub/Pagination.swift b/Sources/HuggingFace/Hub/Pagination.swift index 412ec8f..5289438 100644 --- a/Sources/HuggingFace/Hub/Pagination.swift +++ b/Sources/HuggingFace/Hub/Pagination.swift @@ -43,14 +43,14 @@ func parseNextPageURL(from response: HTTPURLResponse) -> URL? { guard let linkHeader = response.value(forHTTPHeaderField: "Link") else { return nil } - return parseNextPageURL(fromLinkHeader: linkHeader) + return parseNextPageURL(from: linkHeader) } /// Parses a Link header string to extract the next page URL. /// /// - Parameter linkHeader: The Link header value. /// - Returns: The URL for the next page, or `nil` if not found. -func parseNextPageURL(fromLinkHeader linkHeader: String) -> URL? { +func parseNextPageURL(from linkHeader: String) -> URL? { // Parse Link header format: ; rel="next" let links = linkHeader.components(separatedBy: ",") for link in links { From 01786df808c85f853e81a413ea1f6a395c5a3993 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 02:59:32 -0800 Subject: [PATCH 22/37] Remove debug print statement --- Sources/HuggingFace/Shared/HTTPClient.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index d71eaaa..e1a258f 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -90,13 +90,6 @@ final class HTTPClient: @unchecked Sendable { let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) - // Debug: print raw response body to aid troubleshooting - if let bodyString = String(data: data, encoding: .utf8) { - print("HTTPClient response \(httpResponse.statusCode): \(bodyString)") - } else { - print("HTTPClient response \(httpResponse.statusCode): <\(data.count) bytes; non-UTF8>") - } - if T.self == Bool.self { // If T is Bool, we return true for successful response return true as! T From de7bf73ada8bef5daa2555d77e398f2a35cb031d Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:00:43 -0800 Subject: [PATCH 23/37] Fix sendability issues --- .../Helpers/MockURLProtocol.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index 941adb6..1348afd 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -65,27 +65,20 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { } override func startLoading() { - // Capture values before entering the Task to avoid data races. - // We use nonisolated(unsafe) because URLProtocol is inherently non-Sendable, - // but we know the URLSession will keep this instance alive during loading. - let capturedRequest = request - nonisolated(unsafe) let capturedClient = client - nonisolated(unsafe) let capturedSelf = self - Task { do { let (response, data) = try await Self.requestHandlerStorage.executeHandler( - for: capturedRequest + for: self.request ) - capturedClient?.urlProtocol( - capturedSelf, + self.client?.urlProtocol( + self, didReceive: response, cacheStoragePolicy: .notAllowed ) - capturedClient?.urlProtocol(capturedSelf, didLoad: data) - capturedClient?.urlProtocolDidFinishLoading(capturedSelf) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) } catch { - capturedClient?.urlProtocol(capturedSelf, didFailWithError: error) + self.client?.urlProtocol(self, didFailWithError: error) } } } From 1cbad37f8842cf10f26166483b7e9408fed370d2 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:04:41 -0800 Subject: [PATCH 24/37] Update CI workflow to test on Linux --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f81816b..a4e729a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,9 @@ on: branches: ["main"] jobs: - test: - name: Swift ${{ matrix.swift }} on Xcode ${{ matrix.xcode }} - runs-on: ${{ matrix.runs-on }} + test-macos: + name: Swift ${{ matrix.swift }} on macOS ${{ matrix.macos }} with Xcode ${{ matrix.xcode }} + runs-on: macos-${{ matrix.macos }} env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer" strategy: @@ -19,15 +19,52 @@ jobs: - swift: "6.0" xcode: "16.0" runs-on: macos-15 - - swift: "6.1" + - macos: "15" + swift: "6.1" xcode: "16.3" - runs-on: macos-15 - - swift: "6.2" + - macos: "26" + swift: "6.2" xcode: "26.0" - runs-on: macos-26 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Cache Swift Package Manager dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cache/org.swift.swiftpm + .build + key: ${{ runner.os }}-swift-${{ matrix.swift }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swift-${{ matrix.swift }}-spm- + + - name: Build + run: swift build -v + + - name: Test + run: swift test -v + + test-linux: + name: Swift ${{ matrix.swift-version }} on Linux + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + swift-version: + - "6.0.3" + - "6.1.3" + - "6.2.3" + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Swift + uses: vapor/swiftly-action@v0.2 + with: + toolchain: ${{ matrix.swift-version }} - name: Build run: swift build -v From 5dcb623528f09623e86deeee389313ee5981f2d5 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:05:01 -0800 Subject: [PATCH 25/37] swift format . -i -r --- Sources/HuggingFace/Shared/URLSession+Linux.swift | 3 +-- .../InferenceProvidersTests/InferenceClientTests.swift | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/HuggingFace/Shared/URLSession+Linux.swift b/Sources/HuggingFace/Shared/URLSession+Linux.swift index 359856b..e320eed 100644 --- a/Sources/HuggingFace/Shared/URLSession+Linux.swift +++ b/Sources/HuggingFace/Shared/URLSession+Linux.swift @@ -64,8 +64,7 @@ import Foundation /// - request: The URL request to perform. /// - fileURL: The URL of the file to upload. /// - Returns: A tuple containing the response data and URL response. - func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) - { + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let task = self.uploadTask(with: request, fromFile: fileURL) { data, response, error in if let error = error { diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift index 35070d6..a0d0dc0 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/InferenceClientTests.swift @@ -9,7 +9,6 @@ import Testing import FoundationNetworking #endif - @testable import HuggingFace #if swift(>=6.1) From 64876505579e87c5f018915101fd8afa5d0009f7 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:20:12 -0800 Subject: [PATCH 26/37] Update test RequestHandlerStorage to use dictionary of checked continuations --- .../Helpers/MockURLProtocol.swift | 48 +++++++++---------- .../TextToVideoTests.swift | 4 -- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index 1348afd..a6c8213 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -12,22 +12,15 @@ import Testing /// Stores and manages handlers for MockURLProtocol's request handling. private actor RequestHandlerStorage { private var requestHandler: (@Sendable (URLRequest) async throws -> (HTTPURLResponse, Data))? - private var isLocked = false func setHandler( _ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data) - ) async { - // Wait for any existing handler to be released - while isLocked { - try? await Task.sleep(for: .milliseconds(10)) - } + ) { requestHandler = handler - isLocked = true } - func clearHandler() async { + func clearHandler() { requestHandler = nil - isLocked = false } func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) { @@ -95,36 +88,41 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { /// /// Provides mutual exclusion across async test execution to prevent /// interference between parallel test suites using shared mock handlers. - /// - /// Note: We can't use `NSLock` or `OSAllocatedUnfairLock` here because: - /// - They're synchronous locks designed for very short critical sections - /// - They block threads (bad for Swift concurrency's cooperative thread pool) - /// - They can't be held across suspension points (await calls) - /// - /// An actor-based lock is idiomatic for Swift's async/await model. private actor MockURLProtocolLock { static let shared = MockURLProtocolLock() + private var waiters: [CheckedContinuation] = [] private var isLocked = false private init() {} - func withLock(_ operation: @Sendable () async throws -> T) async rethrows -> T { - // Wait for lock to be available - while isLocked { - try? await Task.sleep(for: .milliseconds(10)) + func acquire() async { + if isLocked { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } else { + isLocked = true } + } - // Acquire lock - isLocked = true + func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + isLocked = false + } + } - // Execute operation and ensure lock is released even on error + func withLock(_ operation: @Sendable () async throws -> T) async rethrows -> T { + await acquire() do { let result = try await operation() - isLocked = false + release() return result } catch { - isLocked = false + release() throw error } } diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift index 5a4c90b..489a140 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) From b5c457423ed580d966c693347548d44f9bd87ef8 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:27:17 -0800 Subject: [PATCH 27/37] Ensure consistent URL creation for empty strings across platforms --- Sources/HuggingFace/Hub/Pagination.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/HuggingFace/Hub/Pagination.swift b/Sources/HuggingFace/Hub/Pagination.swift index 5289438..1dbd5f1 100644 --- a/Sources/HuggingFace/Hub/Pagination.swift +++ b/Sources/HuggingFace/Hub/Pagination.swift @@ -64,9 +64,13 @@ func parseNextPageURL(from linkHeader: String) -> URL? { if relPart.contains("rel=\"next\"") || relPart.contains("rel='next'") { // Extract URL from angle brackets let urlString = urlPart.trimmingCharacters(in: CharacterSet(charactersIn: "<>")) - if let url = URL(string: urlString) { - return url + + // Check for empty URL string to ensure consistent behavior across platforms + guard !urlString.isEmpty, let url = URL(string: urlString) else { + continue } + + return url } } From c59ba46524790cd058c2d0e1cd5c2ba7218ab88b Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:37:51 -0800 Subject: [PATCH 28/37] Rename asyncData to data --- Sources/HuggingFace/Hub/HubClient+Files.swift | 12 ++---------- Sources/HuggingFace/OAuth/OAuthClient.swift | 12 ++---------- Sources/HuggingFace/Shared/HTTPClient.swift | 2 +- Sources/HuggingFace/Shared/URLSession+Linux.swift | 4 ++-- 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index bf52e32..2596644 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -213,11 +213,7 @@ public extension HubClient { var request = try await httpClient.createRequest(.get, url: url) request.cachePolicy = cachePolicy - #if canImport(FoundationNetworking) - let (data, response) = try await session.asyncData(for: request) - #else - let (data, response) = try await session.data(for: request) - #endif + let (data, response) = try await session.data(for: request) _ = try httpClient.validateResponse(response, data: data) // Store in cache if we have etag and commit info @@ -550,11 +546,7 @@ public extension HubClient { request.setValue("bytes=0-0", forHTTPHeaderField: "Range") do { - #if canImport(FoundationNetworking) - let (_, response) = try await session.asyncData(for: request) - #else - let (_, response) = try await session.data(for: request) - #endif + let (_, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return File(exists: false) } diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index f5ac973..1375098 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -120,11 +120,7 @@ public actor OAuthClient: Sendable { ] request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - #if canImport(FoundationNetworking) - let (data, response) = try await urlSession.asyncData(for: request) - #else - let (data, response) = try await urlSession.data(for: request) - #endif + let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) @@ -190,11 +186,7 @@ public actor OAuthClient: Sendable { ] request.httpBody = components.percentEncodedQuery?.data(using: .utf8) - #if canImport(FoundationNetworking) - let (data, response) = try await urlSession.asyncData(for: request) - #else - let (data, response) = try await urlSession.data(for: request) - #endif + let (data, response) = try await urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) diff --git a/Sources/HuggingFace/Shared/HTTPClient.swift b/Sources/HuggingFace/Shared/HTTPClient.swift index e1a258f..2a3ca5e 100644 --- a/Sources/HuggingFace/Shared/HTTPClient.swift +++ b/Sources/HuggingFace/Shared/HTTPClient.swift @@ -168,7 +168,7 @@ final class HTTPClient: @unchecked Sendable { #if canImport(FoundationNetworking) // Linux: Use buffered approach since true streaming is not available - let (data, response) = try await session.asyncData(for: request) + let (data, response) = try await session.data(for: request) let httpResponse = try validateResponse(response, data: data) guard (200 ..< 300).contains(httpResponse.statusCode) else { diff --git a/Sources/HuggingFace/Shared/URLSession+Linux.swift b/Sources/HuggingFace/Shared/URLSession+Linux.swift index e320eed..06208e5 100644 --- a/Sources/HuggingFace/Shared/URLSession+Linux.swift +++ b/Sources/HuggingFace/Shared/URLSession+Linux.swift @@ -14,7 +14,7 @@ import Foundation /// /// - Parameter request: The URL request to perform. /// - Returns: A tuple containing the response data and URL response. - func asyncData(for request: URLRequest) async throws -> (Data, URLResponse) { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let task = self.dataTask(with: request) { data, response, error in if let error = error { @@ -141,7 +141,7 @@ import Foundation /// - Parameter request: The URL request to perform. /// - Returns: A tuple containing the response bytes and URL response. func asyncBytes(for request: URLRequest) async throws -> (LinuxAsyncBytes, URLResponse) { - let (data, response) = try await asyncData(for: request) + let (data, response) = try await data(for: request) return (LinuxAsyncBytes(data: data), response) } } From 803764ab4ba5a50705fd9574f77ffe2cf5fb476c Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:39:17 -0800 Subject: [PATCH 29/37] Update test expectations --- .../InferenceProvidersTests/TextToImageTests.swift | 12 ++++++------ .../InferenceProvidersTests/TextToVideoTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift index 4220e3e..8ac5734 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToImageTests.swift @@ -101,7 +101,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "stabilityai/stable-diffusion-xl-base-1.0") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A futuristic city") } #expect(request.url?.path == "/v1/images/generations") @@ -171,7 +171,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "stabilityai/stable-diffusion-xl-base-1.0") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A stunning anime character") } #expect(request.url?.path == "/v1/images/generations") @@ -194,7 +194,7 @@ import Testing let result = try await client.textToImage( model: "stabilityai/stable-diffusion-xl-base-1.0", - prompt: "A beautiful anime character", + prompt: "A stunning anime character", loras: loras ) @@ -226,7 +226,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "stabilityai/stable-diffusion-xl-base-1.0") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A detailed architectural drawing") } #expect(request.url?.path == "/v1/images/generations") @@ -285,7 +285,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "stabilityai/stable-diffusion-xl-base-1.0") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A wide landscape view") } #expect(request.url?.path == "/v1/images/generations") #expect(request.httpMethod == "POST") @@ -390,7 +390,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "stabilityai/stable-diffusion-xl-base-1.0") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "High resolution artwork") } #expect(request.url?.path == "/v1/images/generations") #expect(request.httpMethod == "POST") diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift index 489a140..d19c2b1 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/TextToVideoTests.swift @@ -48,7 +48,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "zeroscope_v2_576w") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A cat playing with a ball") } let response = HTTPURLResponse( @@ -104,7 +104,7 @@ import Testing if let body = request.httpBody { let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] #expect(json["model"] as? String == "zeroscope_v2_576w") - #expect(json["prompt"] as? String == "A beautiful sunset over mountains") + #expect(json["prompt"] as? String == "A dancing robot") } #expect(request.url?.path == "/v1/videos/generations") #expect(request.httpMethod == "POST") From 01b79c9467d5be3cf72b5166a709481e7bbadc5c Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 03:55:40 -0800 Subject: [PATCH 30/37] Switch MockURLProtocol from actor to class with NSLock --- .../Helpers/MockURLProtocol.swift | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift index a6c8213..3e7b477 100644 --- a/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift +++ b/Tests/HuggingFaceTests/Helpers/MockURLProtocol.swift @@ -10,43 +10,52 @@ import Testing // MARK: - Request Handler Storage /// Stores and manages handlers for MockURLProtocol's request handling. -private actor RequestHandlerStorage { - private var requestHandler: (@Sendable (URLRequest) async throws -> (HTTPURLResponse, Data))? +private final class RequestHandlerStorage: @unchecked Sendable { + private let lock = NSLock() + private var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? func setHandler( - _ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data) + _ handler: @Sendable @escaping (URLRequest) throws -> (HTTPURLResponse, Data) ) { + lock.lock() requestHandler = handler + lock.unlock() } func clearHandler() { + lock.lock() requestHandler = nil + lock.unlock() } - func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) { - guard let handler = requestHandler else { + func executeHandler(for request: URLRequest) throws -> (HTTPURLResponse, Data) { + lock.lock() + let handler = requestHandler + lock.unlock() + + guard let handler else { throw NSError( domain: "MockURLProtocolError", code: 0, userInfo: [NSLocalizedDescriptionKey: "No request handler set"] ) } - return try await handler(request) + return try handler(request) } } // MARK: - Mock URL Protocol /// Custom URLProtocol for testing network requests -final class MockURLProtocol: URLProtocol, @unchecked Sendable { +final class MockURLProtocol: URLProtocol { /// Storage for request handlers fileprivate static let requestHandlerStorage = RequestHandlerStorage() /// Set a handler to process mock requests static func setHandler( - _ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data) + _ handler: @Sendable @escaping (URLRequest) throws -> (HTTPURLResponse, Data) ) async { - await requestHandlerStorage.setHandler(handler) + requestHandlerStorage.setHandler(handler) } override class func canInit(with request: URLRequest) -> Bool { @@ -58,21 +67,17 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { } override func startLoading() { - Task { - do { - let (response, data) = try await Self.requestHandlerStorage.executeHandler( - for: self.request - ) - self.client?.urlProtocol( - self, - didReceive: response, - cacheStoragePolicy: .notAllowed - ) - self.client?.urlProtocol(self, didLoad: data) - self.client?.urlProtocolDidFinishLoading(self) - } catch { - self.client?.urlProtocol(self, didFailWithError: error) - } + do { + let (response, data) = try Self.requestHandlerStorage.executeHandler(for: self.request) + self.client?.urlProtocol( + self, + didReceive: response, + cacheStoragePolicy: .notAllowed + ) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) } } @@ -138,13 +143,13 @@ final class MockURLProtocol: URLProtocol, @unchecked Sendable { // Serialize all MockURLProtocol tests to prevent interference try await MockURLProtocolLock.shared.withLock { // Clear handler before test - await MockURLProtocol.requestHandlerStorage.clearHandler() + MockURLProtocol.requestHandlerStorage.clearHandler() // Execute the test try await function() // Clear handler after test - await MockURLProtocol.requestHandlerStorage.clearHandler() + MockURLProtocol.requestHandlerStorage.clearHandler() } } } From d0106ca8970483f59ef3c1f90b796a8f731ee979 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:21:23 -0800 Subject: [PATCH 31/37] Organize fallback MIME type mapping --- Sources/HuggingFace/Hub/HubClient+Files.swift | 95 +++++++++++++------ 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index 2596644..3bf633f 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -656,34 +656,73 @@ private extension URL { // Fallback MIME type lookup for Linux let ext = pathExtension.lowercased() switch ext { - case "json": return "application/json" - case "txt": return "text/plain" - case "html", "htm": return "text/html" - case "css": return "text/css" - case "js": return "application/javascript" - case "xml": return "application/xml" - case "pdf": return "application/pdf" - case "zip": return "application/zip" - case "gz", "gzip": return "application/gzip" - case "tar": return "application/x-tar" - case "png": return "image/png" - case "jpg", "jpeg": return "image/jpeg" - case "gif": return "image/gif" - case "svg": return "image/svg+xml" - case "webp": return "image/webp" - case "mp3": return "audio/mpeg" - case "wav": return "audio/wav" - case "mp4": return "video/mp4" - case "webm": return "video/webm" - case "bin", "safetensors", "gguf", "ggml": return "application/octet-stream" - case "pt", "pth": return "application/octet-stream" - case "onnx": return "application/octet-stream" - case "md": return "text/markdown" - case "yaml", "yml": return "application/x-yaml" - case "toml": return "application/toml" - case "py": return "text/x-python" - case "swift": return "text/x-swift" - default: return "application/octet-stream" + // MARK: - JSON + case "json": + return "application/json" + // MARK: - Text + case "txt": + return "text/plain" + case "md": + return "text/markdown" + // MARK: - HTML and Markup + case "html", "htm": + return "text/html" + case "xml": + return "application/xml" + case "svg": + return "image/svg+xml" + case "yaml", "yml": + return "application/x-yaml" + case "toml": + return "application/toml" + // MARK: - Code + case "js": + return "application/javascript" + case "py": + return "text/x-python" + case "swift": + return "text/x-swift" + case "css": + return "text/css" + // MARK: - Archives and Compressed + case "zip": + return "application/zip" + case "gz", "gzip": + return "application/gzip" + case "tar": + return "application/x-tar" + // MARK: - PDF and Documents + case "pdf": + return "application/pdf" + // MARK: - Images + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + // MARK: - Audio + case "mp3": + return "audio/mpeg" + case "wav": + return "audio/wav" + // MARK: - Video + case "mp4": + return "video/mp4" + case "webm": + return "video/webm" + // MARK: - ML/Model/Raw Data + case "bin", "safetensors", "gguf", "ggml": + return "application/octet-stream" + case "pt", "pth": + return "application/octet-stream" + case "onnx": + return "application/octet-stream" + // MARK: - Default + default: + return "application/octet-stream" } #endif } From 9279a9a7eaebf974658319f15e70e88bb86f1de6 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:24:01 -0800 Subject: [PATCH 32/37] Add more MIME fallbacks --- Sources/HuggingFace/Hub/HubClient+Files.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Sources/HuggingFace/Hub/HubClient+Files.swift b/Sources/HuggingFace/Hub/HubClient+Files.swift index 3bf633f..deec7c4 100644 --- a/Sources/HuggingFace/Hub/HubClient+Files.swift +++ b/Sources/HuggingFace/Hub/HubClient+Files.swift @@ -664,6 +664,10 @@ private extension URL { return "text/plain" case "md": return "text/markdown" + case "csv": + return "text/csv" + case "tsv": + return "text/tab-separated-values" // MARK: - HTML and Markup case "html", "htm": return "text/html" @@ -684,6 +688,8 @@ private extension URL { return "text/x-swift" case "css": return "text/css" + case "ipynb": + return "application/x-ipynb+json" // MARK: - Archives and Compressed case "zip": return "application/zip" @@ -691,6 +697,10 @@ private extension URL { return "application/gzip" case "tar": return "application/x-tar" + case "bz2": + return "application/x-bzip2" + case "7z": + return "application/x-7z-compressed" // MARK: - PDF and Documents case "pdf": return "application/pdf" @@ -703,11 +713,21 @@ private extension URL { return "image/gif" case "webp": return "image/webp" + case "bmp": + return "image/bmp" + case "tiff", "tif": + return "image/tiff" // MARK: - Audio + case "m4a": + return "audio/mp4" case "mp3": return "audio/mpeg" case "wav": return "audio/wav" + case "flac": + return "audio/flac" + case "ogg": + return "audio/ogg" // MARK: - Video case "mp4": return "video/mp4" @@ -720,6 +740,10 @@ private extension URL { return "application/octet-stream" case "onnx": return "application/octet-stream" + case "ckpt": + return "application/octet-stream" + case "npz": + return "application/octet-stream" // MARK: - Default default: return "application/octet-stream" From e6e0943364c982954ad68a56dea541371cf224eb Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:32:20 -0800 Subject: [PATCH 33/37] Fix PKCE random number generation --- Sources/HuggingFace/OAuth/OAuthClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HuggingFace/OAuth/OAuthClient.swift b/Sources/HuggingFace/OAuth/OAuthClient.swift index 1375098..4a5348a 100644 --- a/Sources/HuggingFace/OAuth/OAuthClient.swift +++ b/Sources/HuggingFace/OAuth/OAuthClient.swift @@ -215,7 +215,7 @@ public actor OAuthClient: Sendable { #else // This should be cryptographically secure, see: https://forums.swift.org/t/random-data-uint8-random-or-secrandomcopybytes/56165/9 var generator = SystemRandomNumberGenerator() - buffer = buffer.map { _ in UInt8.random(in: 0 ... 8, using: &generator) } + buffer = buffer.map { _ in UInt8.random(in: 0 ... 255, using: &generator) } #endif let verifier = Data(buffer).urlSafeBase64EncodedString() From 3a80328e2b95a31f39f8263e4c29145e7737dead Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:33:04 -0800 Subject: [PATCH 34/37] Use !os(Windows) for conditional compilation --- Sources/HuggingFace/OAuth/TokenStorage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HuggingFace/OAuth/TokenStorage.swift b/Sources/HuggingFace/OAuth/TokenStorage.swift index b7a1b5e..71fd3e7 100644 --- a/Sources/HuggingFace/OAuth/TokenStorage.swift +++ b/Sources/HuggingFace/OAuth/TokenStorage.swift @@ -67,7 +67,7 @@ public struct FileTokenStorage: Sendable { try data.write(to: fileURL, options: .atomic) // Set file permissions to owner-only (0600) on Unix systems - #if os(Linux) || os(macOS) + #if !os(Windows) try FileManager.default.setAttributes( [.posixPermissions: 0o600], ofItemAtPath: fileURL.path From 1a3e326f858dbe26461f4c8ec614ab2ae2e3fe3e Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:33:22 -0800 Subject: [PATCH 35/37] Remove duplicate import statements --- Tests/HuggingFaceTests/HubTests/DatasetTests.swift | 4 ---- Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift | 4 ---- Tests/HuggingFaceTests/HubTests/ModelTests.swift | 4 ---- Tests/HuggingFaceTests/HubTests/OrganizationTests.swift | 4 ---- Tests/HuggingFaceTests/HubTests/PaperTests.swift | 4 ---- Tests/HuggingFaceTests/HubTests/RepoTests.swift | 4 ---- .../InferenceProvidersTests/ChatCompletionTests.swift | 3 --- .../InferenceProvidersTests/SpeechToTextTests.swift | 4 ---- 8 files changed, 31 deletions(-) diff --git a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift index ecb9880..5b0d505 100644 --- a/Tests/HuggingFaceTests/HubTests/DatasetTests.swift +++ b/Tests/HuggingFaceTests/HubTests/DatasetTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift index 57d0fc5..81fc688 100644 --- a/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift +++ b/Tests/HuggingFaceTests/HubTests/FileOperationsTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/ModelTests.swift b/Tests/HuggingFaceTests/HubTests/ModelTests.swift index ab3af9b..38c36d2 100644 --- a/Tests/HuggingFaceTests/HubTests/ModelTests.swift +++ b/Tests/HuggingFaceTests/HubTests/ModelTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift index e124292..6bf92d9 100644 --- a/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift +++ b/Tests/HuggingFaceTests/HubTests/OrganizationTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/PaperTests.swift b/Tests/HuggingFaceTests/HubTests/PaperTests.swift index 85a97da..e1145cf 100644 --- a/Tests/HuggingFaceTests/HubTests/PaperTests.swift +++ b/Tests/HuggingFaceTests/HubTests/PaperTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/HubTests/RepoTests.swift b/Tests/HuggingFaceTests/HubTests/RepoTests.swift index 1f9d308..599ee82 100644 --- a/Tests/HuggingFaceTests/HubTests/RepoTests.swift +++ b/Tests/HuggingFaceTests/HubTests/RepoTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift index f47a852..713ed78 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/ChatCompletionTests.swift @@ -4,9 +4,6 @@ import Foundation import FoundationNetworking #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif @testable import HuggingFace diff --git a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift index c1ee92a..fa00576 100644 --- a/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift +++ b/Tests/HuggingFaceTests/InferenceProvidersTests/SpeechToTextTests.swift @@ -5,10 +5,6 @@ import Foundation #endif import Testing -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - @testable import HuggingFace #if swift(>=6.1) From e207f9d56850fd8bb46510039b78083016290f58 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:38:52 -0800 Subject: [PATCH 36/37] Fix CI workflow --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4e729a..483cec6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,13 @@ jobs: include: - swift: "6.0" xcode: "16.0" - runs-on: macos-15 - - macos: "15" - swift: "6.1" + macos: "15" + - swift: "6.1" xcode: "16.3" - - macos: "26" - swift: "6.2" + macos: "15" + - swift: "6.2" xcode: "26.0" + macos: "26" timeout-minutes: 10 steps: - name: Checkout code From 070e2092d5ba1804ca9591f744ef353809c3882f Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 16 Dec 2025 04:41:34 -0800 Subject: [PATCH 37/37] Give caveats more prominence in asyncBytes documentation --- Sources/HuggingFace/Shared/URLSession+Linux.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/HuggingFace/Shared/URLSession+Linux.swift b/Sources/HuggingFace/Shared/URLSession+Linux.swift index 06208e5..3364494 100644 --- a/Sources/HuggingFace/Shared/URLSession+Linux.swift +++ b/Sources/HuggingFace/Shared/URLSession+Linux.swift @@ -134,9 +134,12 @@ import Foundation /// Streams bytes from a URL request. /// - /// This provides a simplified streaming interface for Linux where `bytes(for:)` is not available. - /// Note: This implementation buffers the entire response, so it's not suitable for very large responses. - /// For true streaming on Linux, consider using a different HTTP client library. + /// This provides a simplified streaming-like interface for Linux where `bytes(for:)` is not available. + /// + /// - Important: This implementation **buffers the entire response in memory** before streaming bytes. + /// It is **not** true streaming and is **not suitable for large responses or long‑lived streams**, + /// as it may cause excessive memory usage. + /// For true streaming on Linux, consider using a different HTTP client library. /// /// - Parameter request: The URL request to perform. /// - Returns: A tuple containing the response bytes and URL response.