-
Notifications
You must be signed in to change notification settings - Fork 55
Json Progress File #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Json Progress File #391
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -71,10 +71,20 @@ struct Install: SwiftlyCommand { | |
)) | ||
var postInstallFile: FilePath? | ||
|
||
@Option( | ||
help: ArgumentHelp( | ||
"A file path where progress information will be written in JSONL format", | ||
discussion: """ | ||
Progress information will be appended to this file as JSON objects, one per line. | ||
Each progress entry contains timestamp, progress percentage, and a descriptive message. | ||
""" | ||
)) | ||
var progressFile: FilePath? | ||
|
||
@OptionGroup var root: GlobalOptions | ||
|
||
private enum CodingKeys: String, CodingKey { | ||
case version, use, verify, postInstallFile, root | ||
case version, use, verify, postInstallFile, root, progressFile | ||
} | ||
|
||
mutating func run() async throws { | ||
|
@@ -93,7 +103,9 @@ struct Install: SwiftlyCommand { | |
try await validateLinked(ctx) | ||
|
||
var config = try await Config.load(ctx) | ||
let toolchainVersion = try await Self.determineToolchainVersion(ctx, version: self.version, config: &config) | ||
let toolchainVersion = try await Self.determineToolchainVersion( | ||
ctx, version: self.version, config: &config | ||
) | ||
|
||
let (postInstallScript, pathChanged) = try await Self.execute( | ||
ctx, | ||
|
@@ -102,7 +114,8 @@ struct Install: SwiftlyCommand { | |
useInstalledToolchain: self.use, | ||
verifySignature: self.verify, | ||
verbose: self.root.verbose, | ||
assumeYes: self.root.assumeYes | ||
assumeYes: self.root.assumeYes, | ||
progressFile: self.progressFile | ||
) | ||
|
||
let shell = | ||
|
@@ -192,8 +205,9 @@ struct Install: SwiftlyCommand { | |
await ctx.message("Setting up toolchain proxies...") | ||
} | ||
|
||
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( | ||
overwrite) | ||
let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents) | ||
.union( | ||
overwrite) | ||
|
||
for p in proxiesToCreate { | ||
let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p | ||
|
@@ -248,7 +262,8 @@ struct Install: SwiftlyCommand { | |
useInstalledToolchain: Bool, | ||
verifySignature: Bool, | ||
verbose: Bool, | ||
assumeYes: Bool | ||
assumeYes: Bool, | ||
progressFile: FilePath? = nil | ||
) async throws -> (postInstall: String?, pathChanged: Bool) { | ||
guard !config.installedToolchains.contains(version) else { | ||
await ctx.message("\(version) is already installed.") | ||
|
@@ -258,10 +273,11 @@ struct Install: SwiftlyCommand { | |
// Ensure the system is set up correctly before downloading it. Problems that prevent installation | ||
// will throw, while problems that prevent use of the toolchain will be written out as a post install | ||
// script for the user to run afterwards. | ||
let postInstallScript = try await Swiftly.currentPlatform.verifySystemPrerequisitesForInstall( | ||
ctx, platformName: config.platform.name, version: version, | ||
requireSignatureValidation: verifySignature | ||
) | ||
let postInstallScript = try await Swiftly.currentPlatform | ||
.verifySystemPrerequisitesForInstall( | ||
ctx, platformName: config.platform.name, version: version, | ||
requireSignatureValidation: verifySignature | ||
) | ||
|
||
await ctx.message("Installing \(version)") | ||
|
||
|
@@ -296,10 +312,13 @@ struct Install: SwiftlyCommand { | |
} | ||
} | ||
|
||
let animation = PercentProgressAnimation( | ||
stream: stdoutStream, | ||
header: "Downloading \(version)" | ||
) | ||
let animation: ProgressAnimationProtocol = | ||
progressFile != nil | ||
? JsonFileProgressReporter(filePath: progressFile!) | ||
|
||
: PercentProgressAnimation( | ||
stream: stdoutStream, | ||
header: "Downloading \(version)" | ||
) | ||
|
||
var lastUpdate = Date() | ||
|
||
|
@@ -315,7 +334,9 @@ struct Install: SwiftlyCommand { | |
reportProgress: { progress in | ||
let now = Date() | ||
|
||
guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes | ||
guard | ||
lastUpdate.distance(to: now) > 0.25 | ||
|| progress.receivedBytes == progress.totalBytes | ||
else { | ||
return | ||
} | ||
|
@@ -334,7 +355,8 @@ struct Install: SwiftlyCommand { | |
} | ||
) | ||
} catch let notFound as DownloadNotFoundError { | ||
throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") | ||
throw SwiftlyError( | ||
message: "\(version) does not exist at URL \(notFound.url), exiting") | ||
} catch { | ||
animation.complete(success: false) | ||
throw error | ||
|
@@ -401,7 +423,9 @@ struct Install: SwiftlyCommand { | |
} | ||
|
||
/// Utilize the swift.org API along with the provided selector to select a toolchain for install. | ||
public static func resolve(_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector) | ||
public static func resolve( | ||
_ ctx: SwiftlyCoreContext, config: Config, selector: ToolchainSelector | ||
) | ||
async throws -> ToolchainVersion | ||
{ | ||
switch selector { | ||
|
@@ -426,7 +450,8 @@ struct Install: SwiftlyCommand { | |
} | ||
|
||
if let patch { | ||
return .stable(ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) | ||
return .stable( | ||
ToolchainVersion.StableRelease(major: major, minor: minor, patch: patch)) | ||
} | ||
|
||
await ctx.message("Fetching the latest stable Swift \(major).\(minor) release...") | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
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 | ||
|
||
init(filePath: FilePath, encoder: JSONEncoder = JSONEncoder()) { | ||
self.filePath = filePath | ||
self.encoder = encoder | ||
} | ||
|
||
private func writeProgress(_ progress: ProgressInfo) { | ||
let jsonData = try? self.encoder.encode(progress) | ||
guard let jsonData = jsonData, let jsonString = String(data: jsonData, encoding: .utf8) | ||
else { | ||
print("Failed to encode progress entry to JSON") | ||
return | ||
} | ||
|
||
let jsonLine = jsonString + "\n" | ||
|
||
do { | ||
try jsonLine.append(to: self.filePath) | ||
} catch { | ||
print("Failed to write progress entry to \(self.filePath): \(error)") | ||
|
||
} | ||
} | ||
|
||
func update(step: Int, total: Int, text: String) { | ||
assert(step <= total) | ||
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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Where's the requirement for a progress file clear action coming from? I feel that this isn't a necessary function, and is likely to fail on a pipe instead of a regular file. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is part of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think that a no-op makes sense here. The next progress entry will probably go backwards, and it will be up to a client to handle that case. |
||
do { | ||
try FileManager.default.removeItem(atPath: self.filePath.string) | ||
} catch { | ||
print("Failed to clear progress file at \(self.filePath): \(error)") | ||
|
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
import Foundation | ||
@testable import Swiftly | ||
@testable import SwiftlyCore | ||
import SystemPackage | ||
import Testing | ||
|
||
@Suite struct InstallTests { | ||
|
@@ -262,4 +263,37 @@ import Testing | |
try await SwiftlyTests.installMockedToolchain(selector: ToolchainVersion.newStable.name, args: ["--use"]) | ||
try await SwiftlyTests.validateInUse(expected: .newStable) | ||
} | ||
|
||
/// Verify that progress information is written to the progress file when specified. | ||
@Test(.testHomeMockedToolchain()) func installProgressFile() async throws { | ||
let progressFile = fs.mktemp(ext: ".json") | ||
|
||
try await SwiftlyTests.runCommand(Install.self, [ | ||
"install", "5.7.0", | ||
"--post-install-file=\(fs.mktemp())", | ||
"--progress-file=\(progressFile.string)", | ||
]) | ||
|
||
#expect(try await fs.exists(atPath: progressFile)) | ||
|
||
let progressContent = try String(contentsOfFile: progressFile.string) | ||
let lines = progressContent.components(separatedBy: .newlines).filter { !$0.isEmpty } | ||
|
||
#expect(!lines.isEmpty, "Progress file should contain progress entries") | ||
|
||
// Verify that at least one progress entry exists | ||
let hasProgressEntry = lines.contains { line in | ||
|
||
line.contains("\"step\"") && line.contains("\"percent\"") && line.contains("\"timestamp\"") | ||
} | ||
#expect(hasProgressEntry, "Progress file should contain step progress entries") | ||
|
||
// Verify that a completion entry exists | ||
let hasCompletionEntry = lines.contains { line in | ||
line.contains("\"complete\"") && line.contains("\"success\"") | ||
} | ||
#expect(hasCompletionEntry, "Progress file should contain completion entry") | ||
|
||
// Clean up | ||
try FileManager.default.removeItem(atPath: progressFile.string) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Let's spell out that the contract that the client must create this file first, and swiftly will append to it as it progresses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added:"The file must be writable, else an error will be thrown"