From 439e79a347968a93a39c9b7ac12da0a7baa28e0e Mon Sep 17 00:00:00 2001 From: Grzegorz Wikiera Date: Fri, 26 Jan 2018 09:37:31 +0100 Subject: [PATCH 1/2] Added `Handle` protocol to get asynchronous output --- Sources/ShellOut.swift | 76 +++++++++++++++++++------ Tests/ShellOutTests/ShellOutTests.swift | 30 +++++++++- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/Sources/ShellOut.swift b/Sources/ShellOut.swift index 290b9d0..b2b6379 100644 --- a/Sources/ShellOut.swift +++ b/Sources/ShellOut.swift @@ -15,9 +15,9 @@ import Dispatch * - parameter command: The command to run * - parameter arguments: The arguments to pass to the command * - parameter path: The path to execute the commands at (defaults to current folder) - * - parameter outputHandle: Any `FileHandle` that any output (STDOUT) should be redirected to + * - parameter outputHandle: Any `Handle` that any output (STDOUT) should be redirected to * (at the moment this is only supported on macOS) - * - parameter errorHandle: Any `FileHandle` that any error output (STDERR) should be redirected to + * - parameter errorHandle: Any `Handle` that any error output (STDERR) should be redirected to * (at the moment this is only supported on macOS) * * - returns: The output of running the command @@ -29,8 +29,8 @@ import Dispatch @discardableResult public func shellOut(to command: String, arguments: [String] = [], at path: String = ".", - outputHandle: FileHandle? = nil, - errorHandle: FileHandle? = nil) throws -> String { + outputHandle: Handle? = nil, + errorHandle: Handle? = nil) throws -> String { let process = Process() let command = "cd \(path.escapingSpaces) && \(command) \(arguments.joined(separator: " "))" return try process.launchBash(with: command, outputHandle: outputHandle, errorHandle: errorHandle) @@ -41,9 +41,9 @@ import Dispatch * * - parameter commands: The commands to run * - parameter path: The path to execute the commands at (defaults to current folder) - * - parameter outputHandle: Any `FileHandle` that any output (STDOUT) should be redirected to + * - parameter outputHandle: Any `Handle` that any output (STDOUT) should be redirected to * (at the moment this is only supported on macOS) - * - parameter errorHandle: Any `FileHandle` that any error output (STDERR) should be redirected to + * - parameter errorHandle: Any `Handle` that any error output (STDERR) should be redirected to * (at the moment this is only supported on macOS) * * - returns: The output of running the command @@ -54,8 +54,8 @@ import Dispatch */ @discardableResult public func shellOut(to commands: [String], at path: String = ".", - outputHandle: FileHandle? = nil, - errorHandle: FileHandle? = nil) throws -> String { + outputHandle: Handle? = nil, + errorHandle: Handle? = nil) throws -> String { let command = commands.joined(separator: " && ") return try shellOut(to: command, at: path, outputHandle: outputHandle, errorHandle: errorHandle) } @@ -65,8 +65,8 @@ import Dispatch * * - parameter command: The command to run * - parameter path: The path to execute the commands at (defaults to current folder) - * - parameter outputHandle: Any `FileHandle` that any output (STDOUT) should be redirected to - * - parameter errorHandle: Any `FileHandle` that any error output (STDERR) should be redirected to + * - parameter outputHandle: Any `Handle` that any output (STDOUT) should be redirected to + * - parameter errorHandle: Any `Handle` that any error output (STDERR) should be redirected to * * - returns: The output of running the command * - throws: `ShellOutError` in case the command couldn't be performed, or it returned an error @@ -78,8 +78,8 @@ import Dispatch */ @discardableResult public func shellOut(to command: ShellOutCommand, at path: String = ".", - outputHandle: FileHandle? = nil, - errorHandle: FileHandle? = nil) throws -> String { + outputHandle: Handle? = nil, + errorHandle: Handle? = nil) throws -> String { return try shellOut(to: command.string, at: path, outputHandle: outputHandle, errorHandle: errorHandle) } @@ -343,10 +343,52 @@ extension ShellOutError: LocalizedError { } } +/// Protocol adopted by objects that handles command output +public protocol Handle { + /// Method called each time command provide new output data + func handle(data: Data) + + /// Optional method called when command has finished to close the handle + func endHandling() +} + +public extension Handle { + func endHandling() {} +} + +extension FileHandle: Handle { + public func handle(data: Data) { + write(data) + } + + public func endHandling() { + closeFile() + } +} + +/// Handle to get async output from the command. The `handlingClosure` will be called each time new output string appear. +public struct StringHandle: Handle { + private let handlingClosure: (String) -> Void + + /// Default initializer + /// + /// - Parameter handlingClosure: closure called each time new output string is provided + public init(handlingClosure: @escaping (String) -> Void) { + self.handlingClosure = handlingClosure + } + + public func handle(data: Data) { + guard !data.isEmpty else { return } + let output = data.shellOutput() + guard !output.isEmpty else { return } + handlingClosure(output) + } +} + // MARK: - Private private extension Process { - @discardableResult func launchBash(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil) throws -> String { + @discardableResult func launchBash(with command: String, outputHandle: Handle? = nil, errorHandle: Handle? = nil) throws -> String { launchPath = "/bin/bash" arguments = ["-c", command] @@ -370,7 +412,7 @@ private extension Process { outputQueue.async { let data = handler.availableData outputData.append(data) - outputHandle?.write(data) + outputHandle?.handle(data: data) } } @@ -378,7 +420,7 @@ private extension Process { outputQueue.async { let data = handler.availableData errorData.append(data) - errorHandle?.write(data) + errorHandle?.handle(data: data) } } #endif @@ -394,8 +436,8 @@ private extension Process { waitUntilExit() - outputHandle?.closeFile() - errorHandle?.closeFile() + outputHandle?.endHandling() + errorHandle?.endHandling() #if !os(Linux) outputPipe.fileHandleForReading.readabilityHandler = nil diff --git a/Tests/ShellOutTests/ShellOutTests.swift b/Tests/ShellOutTests/ShellOutTests.swift index 90a8fd3..7392f7d 100644 --- a/Tests/ShellOutTests/ShellOutTests.swift +++ b/Tests/ShellOutTests/ShellOutTests.swift @@ -99,7 +99,7 @@ class ShellOutTests: XCTestCase { XCTAssertEqual(error.localizedDescription, expectedErrorDescription) } - func testCapturingOutputWithHandle() throws { + func testCapturingOutputWithFileHandle() throws { let pipe = Pipe() let output = try shellOut(to: "echo", arguments: ["Hello"], outputHandle: pipe.fileHandleForWriting) let capturedData = pipe.fileHandleForReading.readDataToEndOfFile() @@ -107,7 +107,15 @@ class ShellOutTests: XCTestCase { XCTAssertEqual(output + "\n", String(data: capturedData, encoding: .utf8)) } - func testCapturingErrorWithHandle() throws { + func testCapturingOutputWithStringHandle() throws { + var stringHandleOutput = "" + let stringHandle = StringHandle { stringHandleOutput.append($0) } + let output = try shellOut(to: "echo", arguments: ["Hello"], outputHandle: stringHandle) + XCTAssertEqual(output, "Hello") + XCTAssertEqual(output, stringHandleOutput) + } + + func testCapturingErrorWithFileHandle() throws { let pipe = Pipe() do { @@ -124,6 +132,24 @@ class ShellOutTests: XCTestCase { XCTFail("Invalid error type: \(error)") } } + + func testCapturingErrorWithStringHandle() throws { + var stringHandleOutput = "" + let stringHandle = StringHandle { stringHandleOutput.append($0) } + + do { + try shellOut(to: "cd", arguments: ["notADirectory"], errorHandle: stringHandle) + XCTFail("Expected expression to throw") + } catch let error as ShellOutError { + XCTAssertTrue(error.message.contains("notADirectory")) + XCTAssertTrue(error.output.isEmpty) + XCTAssertTrue(error.terminationStatus != 0) + + XCTAssertEqual(error.message, stringHandleOutput) + } catch { + XCTFail("Invalid error type: \(error)") + } + } func testGitCommands() throws { // Setup & clear state From 42978a6b55272b9d886328b1ec4c5d7378321173 Mon Sep 17 00:00:00 2001 From: Grzegorz Wikiera Date: Mon, 29 Jan 2018 11:29:01 +0100 Subject: [PATCH 2/2] Fixed bug with `xcodebuild test` --- Sources/ShellOut.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ShellOut.swift b/Sources/ShellOut.swift index b2b6379..1e3d6b8 100644 --- a/Sources/ShellOut.swift +++ b/Sources/ShellOut.swift @@ -409,16 +409,16 @@ private extension Process { #if !os(Linux) outputPipe.fileHandleForReading.readabilityHandler = { handler in + let data = handler.availableData outputQueue.async { - let data = handler.availableData outputData.append(data) outputHandle?.handle(data: data) } } errorPipe.fileHandleForReading.readabilityHandler = { handler in + let data = handler.availableData outputQueue.async { - let data = handler.availableData errorData.append(data) errorHandle?.handle(data: data) }