JohnSundell/ShellOut

Async output?

jeff-h opened this issue ยท 6 comments

I've skimmed the code and it appears you don't provide any way to get the output of a command as it comes through the pipe. Is this something you'd consider adding? I guess it would provide a "data received" callback argument or something?

I would love to see this added to ShellOut! ๐Ÿ‘ It would also make async scripting with Marathon possible, which would be a big win. Would be happy to accept a PR adding this feature.

Got PR for this here

Edit, the original sounded a bit douchy in the second read. This PR seems to work well on Linux, is it possible to get it to work with Marathon? Thanks for the PR @rob-nash .

Glad someone finds it useful @lf-araujo no worries mate ๐Ÿ‘

Any progress? It doesn't look like anyone made a progress update in years.

This is the closer way to a kind of workaround I found so far:

@discardableResult func shellOut(
    to command: ShellOutCommand,
    arguments: [String] = [],
    at path: String = ".",
    process: Process = .init(),
    errorHandle: FileHandle? = nil,
    liveOutput: @escaping (String) -> Void
) throws -> String {
    let temporaryOutputURL = FileManager.default.temporaryDirectory.appendingPathComponent(
        "shellout_live_output.temp"
    )
    if FileManager.default.fileExists(atPath: temporaryOutputURL.absoluteString) {
        try FileManager.default.removeItem(at: temporaryOutputURL)
    }
    try Data().write(to: temporaryOutputURL)
    let outputHandle = try FileHandle(forWritingTo: temporaryOutputURL)

    #if DEBUG
    print("To read live output file directly in a terminal")
    print("tail -f \(temporaryOutputURL.path)")
    #endif

    outputHandle.waitForDataInBackgroundAndNotify()
    let subscription = NotificationCenter.default.publisher(for: NSNotification.Name.NSFileHandleDataAvailable)
        .tryReduce("", { alreadyDisplayedContent, _ in
            let content = try String(contentsOf: temporaryOutputURL)
            liveOutput(String(content[alreadyDisplayedContent.endIndex...]))

            outputHandle.waitForDataInBackgroundAndNotify()
            return content
        })
        .sink(receiveCompletion: {
            switch $0 {
            case let .failure(error):
                print("Content of live output cannot be read: \(error)")
            case .finished: break
            }
        }, receiveValue: { _ in })

    let output = try shellOut(to: command, at: path, process: process, outputHandle: outputHandle, errorHandle: errorHandle)
    subscription.cancel()

    try FileManager.default.removeItem(at: temporaryOutputURL)

    return output
}

Usage in my script:

try shellOut(
    to: .iOSTest(
        scheme: projectName,
        simulatorName: device.simulatorName,
        derivedDataPath: derivedDataPath,
        testPlan: planName
    )
) { print($0) } // I'm printing everything, but here you can filter what you really want to print out.

I know this is far from being nice, and there is certainly a way to do better.
I spent some times tinkering with outputHandle.readabilityHandler = { fileHandler in where fileHandler.availableData is always an empty data (0 bytes) for some reasons. I guess fileHandler.availableData is always empty because of a race condition, I certainly do something wrong about that.
So the best way I found, which looks over engineered, is to provide a file to shellOut outputHandler, use outputHandle.waitForDataInBackgroundAndNotify on it, use some Combine stuff to print the output...

If you have better approach, I'm all ears. I guess this PR will fix the issue once for all: #30. In the meanwhile, it's good to have a workaround for some scripts I guess.

(I also know that I'm ignoring the live error output, it's only because for my case, I don't need it for my case).