Improve accuracy of progress updates (#144)

This PR resolves the problem of dropped progress updates and ensures the
accuracy of the information provided in the progress bar. Additionally,
it adds the displaying of the finished state to the progress bar.
Requires merging and tagging
https://github.com/apple/containerization/pull/91.
This commit is contained in:
Dmitry Kovba
2025-06-12 23:36:38 -07:00
committed by GitHub
parent 8f2d4d7300
commit 206f3cc07c
7 changed files with 336 additions and 75 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
{
"originHash" : "cb5cf6c245fe60f647e4e106917dc2e6559eda5170af37e63e52e549b2eed566",
"originHash" : "21dad499f34492edb861e54fe1e03ee3b00aa3d7371af6b09253bf04495427b9",
"pins" : [
{
"identity" : "async-http-client",
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/containerization.git",
"state" : {
"revision" : "56c06be9a9473b6df9031dd217551e6ad9a97d29",
"version" : "0.1.0"
"revision" : "4b05e5f2313e881ee048f7063b30e73070fbd1b1",
"version" : "0.1.1"
}
},
{
+1 -1
View File
@@ -26,7 +26,7 @@ if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] {
scDependency = .package(path: path)
scVersion = "latest"
} else {
scVersion = "0.1.0"
scVersion = "0.1.1"
scDependency = .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion))
}
-1
View File
@@ -19,7 +19,6 @@ import ContainerizationError
import Foundation
public struct XPCClient: Sendable {
// Access to `connection` is protected by a lock
private nonisolated(unsafe) let connection: xpc_connection_t
private let q: DispatchQueue?
private let service: String
+65 -34
View File
@@ -61,23 +61,24 @@ extension ProgressBar {
/// Performs a check to see if the progress bar should be finished.
public func checkIfFinished() {
if let totalTasks = state.totalTasks {
var finished = true
var defined = false
if let totalTasks = state.totalTasks, totalTasks > 0 {
// For tasks, we're showing the current task rather then the number of completed tasks.
guard state.tasks > totalTasks else {
return
}
finished = finished && state.tasks == totalTasks
defined = true
}
if let totalItems = state.totalItems {
guard state.items == totalItems else {
return
}
if let totalItems = state.totalItems, totalItems > 0 {
finished = finished && state.items == totalItems
defined = true
}
if let totalSize = state.totalSize {
guard state.size == totalSize else {
return
}
if let totalSize = state.totalSize, totalSize > 0 {
finished = finished && state.size == totalSize
defined = true
}
if defined && finished {
finish()
}
finish()
}
/// Sets the current tasks.
@@ -92,9 +93,14 @@ extension ProgressBar {
/// Performs an addition to the current tasks.
/// - Parameter tasks: The tasks to add to the current tasks.
public func add(tasks toAdd: Int, render: Bool = true) {
let newTasks = state.tasks + toAdd
set(tasks: newTasks, render: render)
public func add(tasks delta: Int, render: Bool = true) {
_state.withLock {
let newTasks = $0.tasks + delta
$0.tasks = newTasks
}
if render {
self.render()
}
}
/// Sets the total tasks.
@@ -108,10 +114,15 @@ extension ProgressBar {
/// Performs an addition to the total tasks.
/// - Parameter totalTasks: The tasks to add to the total tasks.
public func add(totalTasks toAdd: Int, render: Bool = true) {
let totalTasks = state.totalTasks ?? 0
let newTotalTasks = totalTasks + toAdd
set(totalTasks: newTotalTasks, render: render)
public func add(totalTasks delta: Int, render: Bool = true) {
_state.withLock {
let totalTasks = $0.totalTasks ?? 0
let newTotalTasks = totalTasks + delta
$0.totalTasks = newTotalTasks
}
if render {
self.render()
}
}
/// Sets the items name.
@@ -134,9 +145,14 @@ extension ProgressBar {
/// Performs an addition to the current items.
/// - Parameter items: The items to add to the current items.
public func add(items toAdd: Int, render: Bool = true) {
let newItems = state.items + toAdd
set(items: newItems, render: render)
public func add(items delta: Int, render: Bool = true) {
_state.withLock {
let newItems = $0.items + delta
$0.items = newItems
}
if render {
self.render()
}
}
/// Sets the total items.
@@ -150,10 +166,15 @@ extension ProgressBar {
/// Performs an addition to the total items.
/// - Parameter totalItems: The items to add to the total items.
public func add(totalItems toAdd: Int, render: Bool = true) {
let totalItems = state.totalItems ?? 0
let newTotalItems = totalItems + toAdd
set(totalItems: newTotalItems, render: render)
public func add(totalItems delta: Int, render: Bool = true) {
_state.withLock {
let totalItems = $0.totalItems ?? 0
let newTotalItems = totalItems + delta
$0.totalItems = newTotalItems
}
if render {
self.render()
}
}
/// Sets the current size.
@@ -167,9 +188,14 @@ extension ProgressBar {
/// Performs an addition to the current size.
/// - Parameter size: The size to add to the current size.
public func add(size toAdd: Int64, render: Bool = true) {
let newSize = state.size + toAdd
set(size: newSize, render: render)
public func add(size delta: Int64, render: Bool = true) {
_state.withLock {
let newSize = $0.size + delta
$0.size = newSize
}
if render {
self.render()
}
}
/// Sets the total size.
@@ -183,9 +209,14 @@ extension ProgressBar {
/// Performs an addition to the total size.
/// - Parameter totalSize: The size to add to the total size.
public func add(totalSize toAdd: Int64, render: Bool = true) {
let totalSize = state.totalSize ?? 0
let newTotalSize = totalSize + toAdd
set(totalSize: newTotalSize, render: render)
public func add(totalSize delta: Int64, render: Bool = true) {
_state.withLock {
let totalSize = $0.totalSize ?? 0
let newTotalSize = totalSize + delta
$0.totalSize = newTotalSize
}
if render {
self.render()
}
}
}
+60 -35
View File
@@ -20,8 +20,9 @@ import SendableProperty
/// A progress bar that updates itself as tasks are completed.
public final class ProgressBar: Sendable {
let config: ProgressConfig
// `@SendableProperty` adds `_state: Synchronized<State>`, which can be updated inside a lock using `_state.withLock()`.
@SendableProperty
var state: State
var state = State()
@SendableProperty
var printedWidth = 0
let term: FileHandle?
@@ -97,7 +98,7 @@ public final class ProgressBar: Sendable {
printFullDescription()
}
while !isFinished {
while !state.finished {
let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000)
render()
state.iteration += 1
@@ -117,11 +118,15 @@ public final class ProgressBar: Sendable {
/// Finishes the progress bar.
public func finish() {
guard !isFinished else {
guard !state.finished else {
return
}
state.finished = true
// The last render.
render(force: true)
if !config.disableProgressUpdates && !config.clearOnFinish {
displayText(state.output, terminating: "\n")
}
@@ -143,8 +148,8 @@ extension ProgressBar {
return timeDifferenceSeconds
}
func render() {
guard term != nil && !config.disableProgressUpdates && !isFinished else {
func render(force: Bool = false) {
guard term != nil && !config.disableProgressUpdates && (force || !state.finished) else {
return
}
let output = draw()
@@ -154,8 +159,12 @@ extension ProgressBar {
func draw() -> String {
var components = [String]()
if config.showSpinner && !config.showProgressBar {
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
components.append("\(spinnerIcon)")
if !state.finished {
let spinnerIcon = config.theme.getSpinnerIcon(state.iteration)
components.append("\(spinnerIcon)")
} else {
components.append("\(config.theme.done)")
}
}
if config.showTasks, let totalTasks = state.totalTasks {
@@ -176,13 +185,13 @@ extension ProgressBar {
let total = state.totalSize ?? Int64(state.totalItems ?? 0)
if config.showPercent && total > 0 && allowProgress {
components.append("\(state.percent)")
components.append("\(state.finished ? "100%" : state.percent)")
}
if config.showProgressBar, total > 0, allowProgress {
let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */
let remainingWidth = max(config.width - usedWidth, 1 /* the minumum width of a progress bar */)
let barLength = Int(Int64(remainingWidth) * value / total)
let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total)
let barPaddingLength = remainingWidth - barLength
let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))"
components.append("|\(bar)|")
@@ -195,40 +204,56 @@ extension ProgressBar {
if !state.itemsName.isEmpty {
itemsName = " \(state.itemsName)"
}
if let totalItems = state.totalItems {
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
if state.finished {
if let totalItems = state.totalItems {
additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)")
}
} else {
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
if let totalItems = state.totalItems {
additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)")
} else {
additionalComponents.append("\(state.items.formattedNumber())\(itemsName)")
}
}
}
if state.size > 0 && allowProgress {
var formattedCombinedSize = ""
if config.showSize {
var formattedSize = state.size.formattedSize()
formattedSize = adjustFormattedSize(formattedSize)
if let totalSize = state.totalSize {
var formattedTotalSize = totalSize.formattedSize()
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize)
} else {
formattedCombinedSize = formattedSize
if state.finished {
if config.showSize {
if let totalSize = state.totalSize {
var formattedTotalSize = totalSize.formattedSize()
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
additionalComponents.append(formattedTotalSize)
}
}
} else {
var formattedCombinedSize = ""
if config.showSize {
var formattedSize = state.size.formattedSize()
formattedSize = adjustFormattedSize(formattedSize)
if let totalSize = state.totalSize {
var formattedTotalSize = totalSize.formattedSize()
formattedTotalSize = adjustFormattedSize(formattedTotalSize)
formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize)
} else {
formattedCombinedSize = formattedSize
}
}
}
var formattedSpeed = ""
if config.showSpeed {
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
formattedSpeed = adjustFormattedSize(formattedSpeed)
}
var formattedSpeed = ""
if config.showSpeed {
formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)"
formattedSpeed = adjustFormattedSize(formattedSpeed)
}
if config.showSize && config.showSpeed {
additionalComponents.append(formattedCombinedSize)
additionalComponents.append(formattedSpeed)
} else if config.showSize {
additionalComponents.append(formattedCombinedSize)
} else if config.showSpeed {
additionalComponents.append(formattedSpeed)
if config.showSize && config.showSpeed {
additionalComponents.append(formattedCombinedSize)
additionalComponents.append(formattedSpeed)
} else if config.showSize {
additionalComponents.append(formattedCombinedSize)
} else if config.showSpeed {
additionalComponents.append(formattedSpeed)
}
}
}
+4 -1
View File
@@ -18,13 +18,16 @@
public protocol ProgressTheme: Sendable {
/// The icons used to represent a spinner.
var spinner: [String] { get }
/// The icons used to represent a progress bar.
/// The icon used to represent a progress bar.
var bar: String { get }
/// The icon used to indicate that a progress bar finished.
var done: String { get }
}
public struct DefaultProgressTheme: ProgressTheme {
public let spinner = ["", "", "", "", "", "", "", "", "", ""]
public let bar = ""
public let done = ""
}
extension ProgressTheme {
@@ -30,6 +30,16 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task [0s]")
}
func testSpinnerFinished() async throws {
let config = try ProgressConfig(
description: "Task"
)
let progress = ProgressBar(config: config)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task [0s]")
}
func testNoSpinner() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -40,6 +50,17 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "Task [0s]")
}
func testNoSpinnerFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showSpinner: false
)
let progress = ProgressBar(config: config)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "Task [0s]")
}
func testNoTasks() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -93,6 +114,18 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ [0/2] Task [0s]")
}
func testTotalTasksFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showTasks: true,
totalTasks: 2
)
let progress = ProgressBar(config: config)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ [0/2] Task [0s]")
}
func testTotalTasksAdd() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -176,6 +209,19 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% [0s]")
}
func testPercentItemsFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showPercent: true,
totalItems: 2
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% [0s]")
}
func testPercentSize() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -190,6 +236,21 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% [0s]")
}
func testPercentSizeFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showPercent: true,
showSize: false,
showSpeed: false,
totalSize: 2
)
let progress = ProgressBar(config: config)
progress.set(size: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% [0s]")
}
func testNoProgressBar() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -216,6 +277,20 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "Task 50% |██ | [0s]")
}
func testProgressBarFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showProgressBar: true,
totalItems: 2,
width: 57
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "Task 100% |███| [0s]")
}
func testProgressBarMinWidth() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -229,6 +304,20 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "Task 50% | | [0s]")
}
func testProgressBarMinWidthFinished() async throws {
let config = try ProgressConfig(
description: "Task",
showProgressBar: true,
totalItems: 2,
width: 13
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "Task 100% |█| [0s]")
}
func testNoItems() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -260,6 +349,18 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task (1 it) [0s]")
}
func testItemsAddFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showItems: true
)
let progress = ProgressBar(config: config)
progress.add(items: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task [0s]")
}
func testItemsSet() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -294,6 +395,19 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]")
}
func testTotalItemsFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showItems: true,
totalItems: 2
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% (2 it) [0s]")
}
func testTotalItemsAdd() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -361,6 +475,19 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task (1 byte) [0s]")
}
func testSizeAddFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showSize: true,
showSpeed: false
)
let progress = ProgressBar(config: config)
progress.add(size: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task [0s]")
}
func testSizeSet() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -397,6 +524,20 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% (1 byte/2 bytes) [0s]")
}
func testTotalSizeDifferentUnitsFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showSize: true,
showSpeed: false,
totalSize: 2
)
let progress = ProgressBar(config: config)
progress.set(size: 1)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% (2 bytes) [0s]")
}
func testTotalSizeSameUnits() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -410,6 +551,20 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]")
}
func testTotalSizeSameUnitsFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showSize: true,
showSpeed: false,
totalSize: 4
)
let progress = ProgressBar(config: config)
progress.set(size: 2)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% (4 bytes) [0s]")
}
func testTotalSizeAdd() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -463,6 +618,23 @@ final class ProgressBarTests: XCTestCase {
XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it, 2/4 bytes) [0s]")
}
func testItemsAndSizeFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showItems: true,
showSize: true,
showSpeed: false,
totalItems: 2,
totalSize: 4
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.set(size: 2)
progress.finish()
let output = progress.draw()
XCTAssertEqual(output, "✔ Task 100% (2 it, 4 bytes) [0s]")
}
func testNoSpeed() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -487,6 +659,19 @@ final class ProgressBarTests: XCTestCase {
XCTAssertTrue(output.contains("/s"))
}
func testSpeedFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showSpeed: true,
totalSize: 4
)
let progress = ProgressBar(config: config)
progress.set(size: 2)
progress.finish()
let output = progress.draw()
XCTAssertFalse(output.contains("/s"))
}
func testItemsSizeAndSpeed() async throws {
let config = try ProgressConfig(
description: "Task",
@@ -504,6 +689,24 @@ final class ProgressBarTests: XCTestCase {
XCTAssertTrue(output.contains("/s"))
}
func testItemsSizeAndSpeedFinish() async throws {
let config = try ProgressConfig(
description: "Task",
showItems: true,
showSize: true,
showSpeed: true,
totalItems: 2,
totalSize: 4
)
let progress = ProgressBar(config: config)
progress.set(items: 1)
progress.set(size: 2)
progress.finish()
let output = progress.draw()
XCTAssertTrue(output.contains("2 it, 4 bytes"))
XCTAssertFalse(output.contains("/s"))
}
func testNoTime() async throws {
let config = try ProgressConfig(
description: "Task",