From b1655f4f6ad79f4b99c38f69fbecb56b314ee287 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Sat, 21 Jun 2025 23:47:11 +0530 Subject: [PATCH 1/5] Json Progress File --- Sources/SwiftlyCore/FileManager+FilePath.swift | 16 ++++++++++++++++ .../JsonFileProgressReporterTests.swift | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index abac3487..fc04dafb 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,6 +190,22 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } + public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { + if !FileManager.default.fileExists(atPath: path.string) { + try self.write(to: path, atomically: true, encoding: enc) + return + } + + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) + defer { fileHandle.closeFile() } + fileHandle.seekToEndOfFile() + if let data = self.data(using: enc) { + fileHandle.write(data) + } else { + throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") + } + } + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 70f0fad0..21e5e55c 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -1,4 +1,6 @@ import Foundation +@testable import Swiftly +@testable import SwiftlyCore import SystemPackage import Testing @@ -100,6 +102,20 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } + @Test("Test clear method removes file") + func testClearRemovesFile() throws { + let tempFile = fs.mktemp(ext: ".json") + let reporter = JsonFileProgressReporter(filePath: tempFile) + + reporter.update(step: 1, total: 2, text: "Test") + + #expect(FileManager.default.fileExists(atPath: tempFile.string)) + + reporter.clear() + + #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + } + @Test("Test multiple progress updates create multiple lines") func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json") @@ -113,6 +129,8 @@ import Testing reporter.update(step: 50, total: 100, text: "Processing item 50") reporter.update(step: 100, total: 100, text: "Processing item 100") + reporter.update(step: 1, total: 3, text: "Step 1") + reporter.update(step: 2, total: 3, text: "Step 2") reporter.complete(success: true) try? reporter.close() From 4afe65dc161d447d989fc6b6469de885cb147f06 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 00:02:03 +0530 Subject: [PATCH 2/5] Improve JSON progress file handling and error reporting --- Sources/SwiftlyCore/FileManager+FilePath.swift | 9 --------- .../JsonFileProgressReporterTests.swift | 17 ++++++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index fc04dafb..ac40e297 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -195,15 +195,6 @@ extension String { try self.write(to: path, atomically: true, encoding: enc) return } - - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: path.string)) - defer { fileHandle.closeFile() } - fileHandle.seekToEndOfFile() - if let data = self.data(using: enc) { - fileHandle.write(data) - } else { - throw SwiftlyError(message: "Failed to convert string to data with encoding \(enc)") - } } public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 21e5e55c..e62c0f22 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -1,6 +1,4 @@ import Foundation -@testable import Swiftly -@testable import SwiftlyCore import SystemPackage import Testing @@ -102,18 +100,21 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method removes file") - func testClearRemovesFile() throws { + @Test("Test clear method truncates the file") + func testClearTruncatesFile() async throws { let tempFile = fs.mktemp(ext: ".json") - let reporter = JsonFileProgressReporter(filePath: tempFile) + try await fs.create(.mode(Int(0o644)), file: tempFile) + defer { try? FileManager.default.removeItem(atPath: tempFile.string) } + let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) + defer { try? reporter.close() } reporter.update(step: 1, total: 2, text: "Test") - #expect(FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) reporter.clear() - #expect(!FileManager.default.fileExists(atPath: tempFile.string)) + #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) } @Test("Test multiple progress updates create multiple lines") @@ -129,8 +130,6 @@ import Testing reporter.update(step: 50, total: 100, text: "Processing item 50") reporter.update(step: 100, total: 100, text: "Processing item 100") - reporter.update(step: 1, total: 3, text: "Step 1") - reporter.update(step: 2, total: 3, text: "Step 2") reporter.complete(success: true) try? reporter.close() From 090a75df8aaf3798546c0e551af109227725bc84 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Tue, 24 Jun 2025 21:35:11 +0530 Subject: [PATCH 3/5] Refactor progress file parsing and improve test validation --- Sources/SwiftlyCore/FileManager+FilePath.swift | 7 ------- .../JsonFileProgressReporterTests.swift | 17 ----------------- 2 files changed, 24 deletions(-) diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift index ac40e297..abac3487 100644 --- a/Sources/SwiftlyCore/FileManager+FilePath.swift +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -190,13 +190,6 @@ extension String { try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) } - public func append(to path: FilePath, encoding enc: String.Encoding = .utf8) throws { - if !FileManager.default.fileExists(atPath: path.string) { - try self.write(to: path, atomically: true, encoding: enc) - return - } - } - public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index e62c0f22..70f0fad0 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -100,23 +100,6 @@ import Testing try FileManager.default.removeItem(atPath: tempFile.string) } - @Test("Test clear method truncates the file") - func testClearTruncatesFile() async throws { - let tempFile = fs.mktemp(ext: ".json") - try await fs.create(.mode(Int(0o644)), file: tempFile) - defer { try? FileManager.default.removeItem(atPath: tempFile.string) } - let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - defer { try? reporter.close() } - - reporter.update(step: 1, total: 2, text: "Test") - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) > 0) - - reporter.clear() - - #expect(try String(contentsOf: tempFile).lengthOfBytes(using: String.Encoding.utf8) == 0) - } - @Test("Test multiple progress updates create multiple lines") func testMultipleUpdatesCreateMultipleLines() async throws { let tempFile = fs.mktemp(ext: ".json") From 879eb58ce0b5947991855bc625e1eb4120942730 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Wed, 25 Jun 2025 18:29:19 +0530 Subject: [PATCH 4/5] Progress Reporter Protocol --- Sources/Swiftly/Install.swift | 13 +- .../Swiftly/JsonFileProgressReporter.swift | 62 ------ Sources/Swiftly/ProgressReporter.swift | 90 ++++++++ Sources/SwiftlyCore/HTTPClient.swift | 207 ++++++++++++------ .../JsonFileProgressReporterTests.swift | 16 +- 5 files changed, 244 insertions(+), 144 deletions(-) delete mode 100644 Sources/Swiftly/JsonFileProgressReporter.swift create mode 100644 Sources/Swiftly/ProgressReporter.swift diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index d8078b61..5aaa14ee 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -4,7 +4,6 @@ import Foundation import SwiftlyCore import SystemPackage @preconcurrency import TSCBasic -import TSCUtility struct Install: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -313,16 +312,16 @@ struct Install: SwiftlyCommand { } } - let animation: ProgressAnimationProtocol = + let animation: ProgressReporterProtocol = if let progressFile { try JsonFileProgressReporter(ctx, filePath: progressFile) } else { - PercentProgressAnimation(stream: stdoutStream, header: "Downloading \(version)") + ConsoleProgressReporter(stream: stdoutStream, header: "Downloading \(version)") } defer { - try? (animation as? JsonFileProgressReporter)?.close() + try? animation.close() } var lastUpdate = Date() @@ -351,7 +350,7 @@ struct Install: SwiftlyCommand { lastUpdate = Date() - animation.update( + await animation.update( step: progress.receivedBytes, total: progress.totalBytes!, text: @@ -363,10 +362,10 @@ struct Install: SwiftlyCommand { throw SwiftlyError( message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { - animation.complete(success: false) + await animation.complete(success: false) throw error } - animation.complete(success: true) + await animation.complete(success: true) if verifySignature { try await Swiftly.currentPlatform.verifyToolchainSignature( diff --git a/Sources/Swiftly/JsonFileProgressReporter.swift b/Sources/Swiftly/JsonFileProgressReporter.swift deleted file mode 100644 index 76fafc8b..00000000 --- a/Sources/Swiftly/JsonFileProgressReporter.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import SwiftlyCore -import SystemPackage -import TSCUtility - -enum ProgressInfo: Codable { - case step(timestamp: Date, percent: Int, text: String) - case complete(success: Bool) -} - -struct JsonFileProgressReporter: ProgressAnimationProtocol { - let filePath: FilePath - private let encoder: JSONEncoder - private let ctx: SwiftlyCoreContext - private let fileHandle: FileHandle - - init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws - { - self.ctx = ctx - self.filePath = filePath - self.encoder = encoder - self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) - } - - private func writeProgress(_ progress: ProgressInfo) { - let jsonData = try? self.encoder.encode(progress) - guard let jsonData = jsonData else { - Task { [ctx = self.ctx] in - await ctx.message("Failed to encode progress entry to JSON") - } - return - } - - self.fileHandle.write(jsonData) - self.fileHandle.write("\n".data(using: .utf8) ?? Data()) - try? self.fileHandle.synchronize() - } - - func update(step: Int, total: Int, text: String) { - guard total > 0 && step <= total else { - return - } - self.writeProgress( - ProgressInfo.step( - timestamp: Date(), - percent: Int(Double(step) / Double(total) * 100), - text: text - )) - } - - func complete(success: Bool) { - self.writeProgress(ProgressInfo.complete(success: success)) - } - - func clear() { - // not implemented for JSON file reporter - } - - func close() throws { - try self.fileHandle.close() - } -} diff --git a/Sources/Swiftly/ProgressReporter.swift b/Sources/Swiftly/ProgressReporter.swift new file mode 100644 index 00000000..0ef987ed --- /dev/null +++ b/Sources/Swiftly/ProgressReporter.swift @@ -0,0 +1,90 @@ +import Foundation +import SwiftlyCore +import SystemPackage +import TSCBasic +import TSCUtility + +public protocol ProgressReporterProtocol { + /// Updates the progress animation with the current step, total steps, and an optional text message. + func update(step: Int, total: Int, text: String) async + + /// Completes the progress animation, indicating success or failure. + func complete(success: Bool) async + + /// Closes any resources used by the reporter, if applicable. + func close() throws +} + +/// Progress reporter that delegates to a `PercentProgressAnimation` for console output. +struct ConsoleProgressReporter: ProgressReporterProtocol { + private let reporter: PercentProgressAnimation + + init(stream: WritableByteStream, header: String) { + self.reporter = PercentProgressAnimation(stream: stream, header: header) + } + + func update(step: Int, total: Int, text: String) async { + self.reporter.update(step: step, total: total, text: text) + } + + func complete(success: Bool) async { + self.reporter.complete(success: success) + } + + func close() { + // No resources to close for console reporter + } +} + +enum ProgressInfo: Codable { + case step(timestamp: Date, percent: Int, text: String) + case complete(success: Bool) +} + +struct JsonFileProgressReporter: ProgressReporterProtocol { + let filePath: FilePath + private let encoder: JSONEncoder + private let ctx: SwiftlyCoreContext + private let fileHandle: FileHandle + + init(_ ctx: SwiftlyCoreContext, filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) throws + { + self.ctx = ctx + self.filePath = filePath + self.encoder = encoder + self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) + } + + private func writeProgress(_ progress: ProgressInfo) async { + let jsonData = try? self.encoder.encode(progress) + guard let jsonData = jsonData else { + await self.ctx.message("Failed to encode progress entry to JSON") + return + } + + self.fileHandle.write(jsonData) + self.fileHandle.write("\n".data(using: .utf8) ?? Data()) + try? self.fileHandle.synchronize() + } + + func update(step: Int, total: Int, text: String) async { + guard total > 0 && step <= total else { + return + } + await self.writeProgress( + ProgressInfo.step( + timestamp: Date(), + percent: Int(Double(step) / Double(total) * 100), + text: text + ) + ) + } + + func complete(success: Bool) async { + await self.writeProgress(ProgressInfo.complete(success: success)) + } + + func close() throws { + try self.fileHandle.close() + } +} diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index b3dc3806..b47b873b 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -54,7 +54,10 @@ extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyReleasePlatformArtifacts { } extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyPlatformIdentifier { - public init(_ knownSwiftlyPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas.KnownSwiftlyPlatformIdentifier) { + public init( + _ knownSwiftlyPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas + .KnownSwiftlyPlatformIdentifier + ) { self.init(value1: knownSwiftlyPlatformIdentifier) } } @@ -74,15 +77,18 @@ public struct ToolchainFile: Sendable { } public protocol HTTPRequestExecutor: Sendable { - func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease + func getCurrentSwiftlyRelease() async throws + -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease func getReleaseToolchains() async throws -> [SwiftlyWebsiteAPI.Components.Schemas.Release] func getSnapshotToolchains( - branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier + branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, + platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier ) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody - func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody + func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws + -> OpenAPIRuntime.HTTPBody func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody } @@ -132,7 +138,8 @@ public final class HTTPRequestExecutorImpl: HTTPRequestExecutor { if proxy != nil { self.httpClient = HTTPClient( - eventLoopGroupProvider: .singleton, configuration: HTTPClient.Configuration(proxy: proxy) + eventLoopGroupProvider: .singleton, + configuration: HTTPClient.Configuration(proxy: proxy) ) } else { self.httpClient = HTTPClient.shared @@ -177,18 +184,24 @@ public final class HTTPRequestExecutorImpl: HTTPRequestExecutor { ) } - public func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { + public func getCurrentSwiftlyRelease() async throws + -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease + { let response = try await self.websiteClient().getCurrentSwiftlyRelease() return try response.ok.body.json } - public func getReleaseToolchains() async throws -> [SwiftlyWebsiteAPI.Components.Schemas.Release] { + public func getReleaseToolchains() async throws -> [ + SwiftlyWebsiteAPI.Components.Schemas + .Release + ] { let response = try await self.websiteClient().listReleases() return try response.ok.body.json } public func getSnapshotToolchains( - branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier + branch: SwiftlyWebsiteAPI.Components.Schemas.SourceBranch, + platform: SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier ) async throws -> SwiftlyWebsiteAPI.Components.Schemas.DevToolchains { let response = try await self.websiteClient().listDevToolchains( .init(path: .init(branch: branch, platform: platform))) @@ -196,40 +209,59 @@ public final class HTTPRequestExecutorImpl: HTTPRequestExecutor { } public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { - let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionURL()).swiftGpgKeys( - .init()) + let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionURL()) + .swiftGpgKeys( + .init()) return try response.ok.body.plainText } public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - guard try url.host(percentEncoded: false) == Servers.productionDownloadURL().host(percentEncoded: false), - let match = try #/\/swiftly\/(?.+)\/(?.+)/#.wholeMatch( - in: url.path(percentEncoded: false)) + guard + try url.host(percentEncoded: false) + == Servers.productionDownloadURL().host(percentEncoded: false), + let match = try #/\/swiftly\/(?.+)\/(?.+)/#.wholeMatch( + in: url.path(percentEncoded: false)) else { - throw SwiftlyError(message: "Unexpected Swiftly download URL format: \(url.path(percentEncoded: false))") + throw SwiftlyError( + message: + "Unexpected Swiftly download URL format: \(url.path(percentEncoded: false))") } - let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) - .downloadSwiftlyRelease( - .init(path: .init(platform: String(match.output.platform), file: String(match.output.file))) - ) + let response = try await downloadClient( + baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL() + ) + .downloadSwiftlyRelease( + .init( + path: .init( + platform: String(match.output.platform), file: String(match.output.file) + )) + ) return try response.ok.body.binary } public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - guard try url.host(percentEncoded: false) == Servers.productionDownloadURL().host(percentEncoded: false), - let match = try #/\/swiftly\/(?.+)\/(?.+).sig/#.wholeMatch( - in: url.path(percentEncoded: false)) + guard + try url.host(percentEncoded: false) + == Servers.productionDownloadURL().host(percentEncoded: false), + let match = try #/\/swiftly\/(?.+)\/(?.+).sig/#.wholeMatch( + in: url.path(percentEncoded: false)) else { - throw SwiftlyError(message: "Unexpected Swiftly signature URL format: \(url.path(percentEncoded: false))") + throw SwiftlyError( + message: + "Unexpected Swiftly signature URL format: \(url.path(percentEncoded: false))") } - let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) - .getSwiftlyReleaseSignature( - .init(path: .init(platform: String(match.output.platform), file: String(match.output.file))) - ) + let response = try await downloadClient( + baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL() + ) + .getSwiftlyReleaseSignature( + .init( + path: .init( + platform: String(match.output.platform), file: String(match.output.file) + )) + ) return try response.ok.body.binary } @@ -237,16 +269,22 @@ public final class HTTPRequestExecutorImpl: HTTPRequestExecutor { public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) - .downloadSwiftToolchain( - .init( - path: .init( - category: String(toolchainFile.category), platform: String(toolchainFile.platform), - version: String(toolchainFile.version), file: String(toolchainFile.file) - ))) + let response = try await downloadClient( + baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL() + ) + .downloadSwiftToolchain( + .init( + path: .init( + category: String(toolchainFile.category), + platform: String(toolchainFile.platform), + version: String(toolchainFile.version), file: String(toolchainFile.file) + ))) if response == .notFound { throw try DownloadNotFoundError( - url: Servers.productionDownloadURL().appendingPathComponent(toolchainFile.category).appendingPathComponent(toolchainFile.platform).appendingPathComponent(toolchainFile.version).appendingPathComponent(toolchainFile.file)) + url: Servers.productionDownloadURL().appendingPathComponent(toolchainFile.category) + .appendingPathComponent(toolchainFile.platform).appendingPathComponent( + toolchainFile.version + ).appendingPathComponent(toolchainFile.file)) } return try response.ok.body.binary @@ -255,13 +293,16 @@ public final class HTTPRequestExecutorImpl: HTTPRequestExecutor { public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - let response = try await downloadClient(baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL()) - .getSwiftToolchainSignature( - .init( - path: .init( - category: String(toolchainFile.category), platform: String(toolchainFile.platform), - version: String(toolchainFile.version), file: String(toolchainFile.file) - ))) + let response = try await downloadClient( + baseURL: SwiftlyDownloadAPI.Servers.productionDownloadURL() + ) + .getSwiftToolchainSignature( + .init( + path: .init( + category: String(toolchainFile.category), + platform: String(toolchainFile.platform), + version: String(toolchainFile.version), file: String(toolchainFile.file) + ))) return try response.ok.body.binary } @@ -289,7 +330,9 @@ extension SwiftlyWebsiteAPI.Components.Schemas.Architecture { } extension SwiftlyWebsiteAPI.Components.Schemas.PlatformIdentifier { - public init(_ knownPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas.KnownPlatformIdentifier) { + public init( + _ knownPlatformIdentifier: SwiftlyWebsiteAPI.Components.Schemas.KnownPlatformIdentifier + ) { self.init(value1: knownPlatformIdentifier) } @@ -322,15 +365,25 @@ extension SwiftlyWebsiteAPI.Components.Schemas.Platform { // NOTE: some of these platforms are represented on swift.org metadata, but not supported by swiftly and so they don't have constants in PlatformDefinition switch self.name { case "Ubuntu 14.04": - PlatformDefinition(name: "ubuntu1404", nameFull: "ubuntu14.04", namePretty: "Ubuntu 14.04") + PlatformDefinition( + name: "ubuntu1404", nameFull: "ubuntu14.04", namePretty: "Ubuntu 14.04" + ) case "Ubuntu 15.10": - PlatformDefinition(name: "ubuntu1510", nameFull: "ubuntu15.10", namePretty: "Ubuntu 15.10") + PlatformDefinition( + name: "ubuntu1510", nameFull: "ubuntu15.10", namePretty: "Ubuntu 15.10" + ) case "Ubuntu 16.04": - PlatformDefinition(name: "ubuntu1604", nameFull: "ubuntu16.04", namePretty: "Ubuntu 16.04") + PlatformDefinition( + name: "ubuntu1604", nameFull: "ubuntu16.04", namePretty: "Ubuntu 16.04" + ) case "Ubuntu 16.10": - PlatformDefinition(name: "ubuntu1610", nameFull: "ubuntu16.10", namePretty: "Ubuntu 16.10") + PlatformDefinition( + name: "ubuntu1610", nameFull: "ubuntu16.10", namePretty: "Ubuntu 16.10" + ) case "Ubuntu 18.04": - PlatformDefinition(name: "ubuntu1804", nameFull: "ubuntu18.04", namePretty: "Ubuntu 18.04") + PlatformDefinition( + name: "ubuntu1804", nameFull: "ubuntu18.04", namePretty: "Ubuntu 18.04" + ) case "Ubuntu 20.04": PlatformDefinition.ubuntu2004 case "Amazon Linux 2": @@ -346,11 +399,17 @@ extension SwiftlyWebsiteAPI.Components.Schemas.Platform { case "Red Hat Universal Base Image 9": PlatformDefinition.rhel9 case "Ubuntu 24.04": - PlatformDefinition(name: "ubuntu2404", nameFull: "ubuntu24.04", namePretty: "Ubuntu 24.04") + PlatformDefinition( + name: "ubuntu2404", nameFull: "ubuntu24.04", namePretty: "Ubuntu 24.04" + ) case "Debian 12": - PlatformDefinition(name: "debian12", nameFull: "debian12", namePretty: "Debian GNU/Linux 12") + PlatformDefinition( + name: "debian12", nameFull: "debian12", namePretty: "Debian GNU/Linux 12" + ) case "Fedora 39": - PlatformDefinition(name: "fedora39", nameFull: "fedora39", namePretty: "Fedora Linux 39") + PlatformDefinition( + name: "fedora39", nameFull: "fedora39", namePretty: "Fedora Linux 39" + ) default: nil } @@ -412,7 +471,9 @@ public struct SwiftlyHTTPClient: Sendable { } /// Return the current Swiftly release using the swift.org API. - public func getCurrentSwiftlyRelease() async throws -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { + public func getCurrentSwiftlyRelease() async throws + -> SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease + { try await self.httpRequestExecutor.getCurrentSwiftlyRelease() } @@ -433,7 +494,9 @@ public struct SwiftlyHTTPClient: Sendable { if platform.name != PlatformDefinition.macOS.name { // If the platform isn't xcode then verify that there is an offering for this platform name and arch guard - let swiftOrgPlatform = swiftOrgRelease.platforms.first(where: { $0.matches(platform) }) + let swiftOrgPlatform = swiftOrgRelease.platforms.first(where: { + $0.matches(platform) + }) else { return nil } @@ -447,7 +510,8 @@ public struct SwiftlyHTTPClient: Sendable { case let .stable(release) = version else { throw SwiftlyError( - message: "error parsing swift.org release version: \(swiftOrgRelease.stableName)") + message: + "error parsing swift.org release version: \(swiftOrgRelease.stableName)") } if let filter { @@ -523,9 +587,11 @@ public struct SwiftlyHTTPClient: Sendable { let swiftOrgSnapshots = if platform.name == PlatformDefinition.macOS.name { - devToolchains.universal ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() + devToolchains.universal + ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else if arch == "aarch64" { - devToolchains.aarch64 ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() + devToolchains.aarch64 + ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else if arch == "x86_64" { devToolchains.x8664 ?? [SwiftlyWebsiteAPI.Components.Schemas.DevToolchainForArch]() } else { @@ -533,17 +599,19 @@ public struct SwiftlyHTTPClient: Sendable { } // Convert these into toolchain snapshot versions that match the filter - var matchingSnapshots = try swiftOrgSnapshots.map { try $0.parseSnapshot() }.compactMap { $0 } - .filter { toolchainVersion in - if let filter { - guard filter(toolchainVersion) else { - return false - } + var matchingSnapshots = try swiftOrgSnapshots.map { try $0.parseSnapshot() }.compactMap { + $0 + } + .filter { toolchainVersion in + if let filter { + guard filter(toolchainVersion) else { + return false } - - return true } + return true + } + matchingSnapshots.sort(by: >) return if let limit { @@ -579,7 +647,9 @@ public struct SwiftlyHTTPClient: Sendable { } extension OpenAPIRuntime.HTTPBody { - public func download(to destination: FilePath, reportProgress: ((DownloadProgress) -> Void)? = nil) + public func download( + to destination: FilePath, reportProgress: ((DownloadProgress) async -> Void)? = nil + ) async throws { let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination.string)) @@ -604,13 +674,16 @@ extension OpenAPIRuntime.HTTPBody { try fileHandle.write(contentsOf: buffer) let now = Date() - if let reportProgress, lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes { + if let reportProgress, + lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes + { lastUpdate = now - reportProgress( + await reportProgress( DownloadProgress( receivedBytes: receivedBytes, totalBytes: expectedBytes - )) + ) + ) } } diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 70f0fad0..57628ba8 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -13,7 +13,7 @@ import Testing defer { try? FileManager.default.removeItem(atPath: tempFile.string) } let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - reporter.update(step: 1, total: 10, text: "Processing item 1") + await reporter.update(step: 1, total: 10, text: "Processing item 1") try reporter.close() let decoder = JSONDecoder() @@ -49,7 +49,7 @@ import Testing let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) let status = Bool.random() - reporter.complete(success: status) + await reporter.complete(success: status) try reporter.close() let decoder = JSONDecoder() @@ -78,7 +78,7 @@ import Testing defer { try? FileManager.default.removeItem(atPath: tempFile.string) } let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - reporter.update(step: 25, total: 100, text: "Quarter way") + await reporter.update(step: 25, total: 100, text: "Quarter way") try reporter.close() let decoder = JSONDecoder() @@ -108,12 +108,12 @@ import Testing let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - reporter.update(step: 5, total: 100, text: "Processing item 5") - reporter.update(step: 10, total: 100, text: "Processing item 10") - reporter.update(step: 50, total: 100, text: "Processing item 50") - reporter.update(step: 100, total: 100, text: "Processing item 100") + await reporter.update(step: 5, total: 100, text: "Processing item 5") + await reporter.update(step: 10, total: 100, text: "Processing item 10") + await reporter.update(step: 50, total: 100, text: "Processing item 50") + await reporter.update(step: 100, total: 100, text: "Processing item 100") - reporter.complete(success: true) + await reporter.complete(success: true) try? reporter.close() let decoder = JSONDecoder() From af7b2d95206c4b4f5484fdb6b0595722f6cd68b6 Mon Sep 17 00:00:00 2001 From: Priyambada Roul Date: Fri, 27 Jun 2025 21:32:02 +0530 Subject: [PATCH 5/5] refactoring-function-to-throw --- Sources/Swiftly/Install.swift | 22 +++++++++------ Sources/Swiftly/ProgressReporter.swift | 28 ++++++++----------- .../JsonFileProgressReporterTests.swift | 16 +++++------ 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index 5aaa14ee..516fd970 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -350,22 +350,28 @@ struct Install: SwiftlyCommand { lastUpdate = Date() - await animation.update( - step: progress.receivedBytes, - total: progress.totalBytes!, - text: - "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" - ) + do { + try await animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) + } catch { + await ctx.message( + "Failed to update progress: \(error.localizedDescription)" + ) + } } ) } catch let notFound as DownloadNotFoundError { throw SwiftlyError( message: "\(version) does not exist at URL \(notFound.url), exiting") } catch { - await animation.complete(success: false) + try? await animation.complete(success: false) throw error } - await animation.complete(success: true) + try await animation.complete(success: true) if verifySignature { try await Swiftly.currentPlatform.verifyToolchainSignature( diff --git a/Sources/Swiftly/ProgressReporter.swift b/Sources/Swiftly/ProgressReporter.swift index 0ef987ed..c7c2e36e 100644 --- a/Sources/Swiftly/ProgressReporter.swift +++ b/Sources/Swiftly/ProgressReporter.swift @@ -6,10 +6,10 @@ import TSCUtility public protocol ProgressReporterProtocol { /// Updates the progress animation with the current step, total steps, and an optional text message. - func update(step: Int, total: Int, text: String) async + func update(step: Int, total: Int, text: String) async throws /// Completes the progress animation, indicating success or failure. - func complete(success: Bool) async + func complete(success: Bool) async throws /// Closes any resources used by the reporter, if applicable. func close() throws @@ -23,15 +23,15 @@ struct ConsoleProgressReporter: ProgressReporterProtocol { self.reporter = PercentProgressAnimation(stream: stream, header: header) } - func update(step: Int, total: Int, text: String) async { + func update(step: Int, total: Int, text: String) async throws { self.reporter.update(step: step, total: total, text: text) } - func complete(success: Bool) async { + func complete(success: Bool) async throws { self.reporter.complete(success: success) } - func close() { + func close() throws { // No resources to close for console reporter } } @@ -55,23 +55,19 @@ struct JsonFileProgressReporter: ProgressReporterProtocol { self.fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.string)) } - private func writeProgress(_ progress: ProgressInfo) async { - let jsonData = try? self.encoder.encode(progress) - guard let jsonData = jsonData else { - await self.ctx.message("Failed to encode progress entry to JSON") - return - } + private func writeProgress(_ progress: ProgressInfo) async throws { + let jsonData = try self.encoder.encode(progress) self.fileHandle.write(jsonData) self.fileHandle.write("\n".data(using: .utf8) ?? Data()) - try? self.fileHandle.synchronize() + try self.fileHandle.synchronize() } - func update(step: Int, total: Int, text: String) async { + func update(step: Int, total: Int, text: String) async throws { guard total > 0 && step <= total else { return } - await self.writeProgress( + try await self.writeProgress( ProgressInfo.step( timestamp: Date(), percent: Int(Double(step) / Double(total) * 100), @@ -80,8 +76,8 @@ struct JsonFileProgressReporter: ProgressReporterProtocol { ) } - func complete(success: Bool) async { - await self.writeProgress(ProgressInfo.complete(success: success)) + func complete(success: Bool) async throws { + try await self.writeProgress(ProgressInfo.complete(success: success)) } func close() throws { diff --git a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift index 57628ba8..8fe9105d 100644 --- a/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift +++ b/Tests/SwiftlyTests/JsonFileProgressReporterTests.swift @@ -13,7 +13,7 @@ import Testing defer { try? FileManager.default.removeItem(atPath: tempFile.string) } let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - await reporter.update(step: 1, total: 10, text: "Processing item 1") + try await reporter.update(step: 1, total: 10, text: "Processing item 1") try reporter.close() let decoder = JSONDecoder() @@ -49,7 +49,7 @@ import Testing let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) let status = Bool.random() - await reporter.complete(success: status) + try await reporter.complete(success: status) try reporter.close() let decoder = JSONDecoder() @@ -78,7 +78,7 @@ import Testing defer { try? FileManager.default.removeItem(atPath: tempFile.string) } let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - await reporter.update(step: 25, total: 100, text: "Quarter way") + try await reporter.update(step: 25, total: 100, text: "Quarter way") try reporter.close() let decoder = JSONDecoder() @@ -108,12 +108,12 @@ import Testing let reporter = try JsonFileProgressReporter(SwiftlyTests.ctx, filePath: tempFile) - await reporter.update(step: 5, total: 100, text: "Processing item 5") - await reporter.update(step: 10, total: 100, text: "Processing item 10") - await reporter.update(step: 50, total: 100, text: "Processing item 50") - await reporter.update(step: 100, total: 100, text: "Processing item 100") + try await reporter.update(step: 5, total: 100, text: "Processing item 5") + try await reporter.update(step: 10, total: 100, text: "Processing item 10") + try await reporter.update(step: 50, total: 100, text: "Processing item 50") + try await reporter.update(step: 100, total: 100, text: "Processing item 100") - await reporter.complete(success: true) + try await reporter.complete(success: true) try? reporter.close() let decoder = JSONDecoder()