Skip to content

Commit

Permalink
Initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
alexrozanski committed Apr 5, 2023
1 parent 5fa39b0 commit e201b18
Show file tree
Hide file tree
Showing 5 changed files with 547 additions and 0 deletions.
102 changes: 102 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore

## User settings
xcuserdata/

## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout

## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3

## Obj-C/Swift specific
*.hmap

## App packaging
*.ipa
*.dSYM.zip
*.dSYM

## Playgrounds
timeline.xctimeline
playground.xcworkspace

# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm

.build/

# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace

# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts

Carthage/Build/

# Accio dependency management
Dependencies/
.accio/

# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control

fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output

# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode

iOSInjectionProject/

# VS Code
.vscode
25 changes: 25 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "Coquille",
platforms: [
.macOS(.v10_15)
],
products: [
.library(
name: "Coquille",
targets: ["Coquille"])
],
dependencies: [],
targets: [
.target(
name: "Coquille",
dependencies: []),
.testTarget(
name: "CoquilleTests",
dependencies: ["Coquille"]),
]
)
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Coquille

A simple Swift wrapper around `Process` supporting Swift Concurrency and streamed output from `stdout` and `stderr`.

## Requirements

macOS 10.15+

## Installation

Add Coquille to your project using Xcode (File > Add Packages...) or by adding it to your project's `Package.swift` file:

dependencies: [
.package(url: "https://github.com/alexrozanski/Coquille.git", from: "0.1.0")
]

## Usage

Coquille exposes its own `Process` class which you can interact with to execute commands. `Process.run()` is an `async` function so you can just `await` the exit code:

```swift
import Coquille

let process = Process(commandString: "pwd"))
_ = try await process.run() // Prints `pwd` to `stdout`

// Use `command:` for more easily working with variable command-line arguments
let deps = ["numpy", "torch"]
let process = Process(command: .init("python3", arguments: ["-m", "pip", "install"] + deps)))
_ = try await process.run()
```

### I/O

By default `Process` pipes output from the spawned process to `stdout` and `stderr`. This can be configured with `printStdout` and `printStderr`:

```swift
import Coquille

let process = Process(commandString: "brew install wget", printStderr: false))
_ = try await process.run() // Pipes standard output to `stdout` but will not pipe error output to `stderr`
```

You can also pass an `OutputHandler` for both stdout and stderr which will stream contents from both:

```swift
import Coquille

let process = Process(
commandString: "swift build",
stdout: { stdout in
...
},
stderr: { stderr in
...
})
_ = try await process.run() // Streams standard and error output to the handlers provided to `stdout:` and `stderr:`
```

### Exit Codes

```swift
// `isSuccess` can be used to test the exit code for success
let hasRuby = (try await Process(commandString: "which ruby").run()).isSuccess

// Use `errorCode` to get a nonzero exit code
if let errorCode = (try await Process(commandString: "swift build").run()).errorCode {
switch errorCode {
case 127:
// Command not found
default:
...
}
}
```

## Acknowledgements

Thanks to [Ben Chatelain](https://github.com/phatblat) for their [blog post](https://phatbl.at/2019/01/08/intercepting-stdout-in-swift.html) on intercepting stdout, used
to implement some of the tests in the test suite.
171 changes: 171 additions & 0 deletions Sources/Coquille/Process.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import Foundation

public class Process {
public typealias OutputHandler = (String) -> Void

public enum Status {
case success
case failure(_ code: Int32)

var isSuccess: Bool {
switch self {
case .success: return true
case .failure: return false
}
}

var isFailure: Bool {
switch self {
case .success: return false
case .failure: return true
}
}

var errorCode: Int32? {
switch self {
case .success: return nil
case .failure(let errorCode): return errorCode
}
}
}

public struct Command {
let name: String
let arguments: [String]

init(_ name: String, arguments: [String]) {
self.name = name
self.arguments = arguments
}
}

public enum Output {
case stdout
case stderr
case handler(OutputHandler)
}

let arguments: [String]

let stdout: Output?
let stderr: Output?

// MARK: - Initializers

public init(command: Command) {
self.arguments = [command.name] + command.arguments
self.stdout = .stdout
self.stderr = .stderr
}

public init(command: Command, printStdout: Bool = true, printStderr: Bool = true) {
self.arguments = [command.name] + command.arguments
self.stdout = printStdout ? .stdout : nil
self.stderr = printStderr ? .stderr : nil
}

public init(command: Command, stdout: OutputHandler? = nil) {
self.arguments = [command.name] + command.arguments
self.stdout = stdout.map { .handler($0) }
self.stderr = nil
}

public init(command: Command, stdout: OutputHandler? = nil, stderr: OutputHandler? = nil) {
self.arguments = [command.name] + command.arguments
self.stdout = stdout.map { .handler($0) }
self.stderr = stderr.map { .handler($0) }
}

// MARK: - Convenience Initializers

public init(commandString: String) {
self.arguments = commandString.components(separatedBy: " ")
self.stdout = .stdout
self.stderr = .stderr
}

public init(commandString: String, printStdout: Bool = true, printStderr: Bool = true) {
self.arguments = commandString.components(separatedBy: " ")
self.stdout = printStdout ? .stdout : nil
self.stderr = printStderr ? .stderr : nil
}


public init(commandString: String, stdout: OutputHandler? = nil) {
self.arguments = commandString.components(separatedBy: " ")
self.stdout = stdout.map { .handler($0) }
self.stderr = nil
}

public init(commandString: String, stdout: OutputHandler? = nil, stderr: OutputHandler? = nil) {
self.arguments = commandString.components(separatedBy: " ")
self.stdout = stdout.map { .handler($0) }
self.stderr = stderr.map { .handler($0) }
}

// MARK: - Running tasks

public func run() async throws -> Status {
return try await withCheckedThrowingContinuation { continuation in
do {
try _run { status in
continuation.resume(returning: status)
}
} catch {
continuation.resume(throwing: error)
}
}
}

private func _run(with completionHandler: (Status) -> Void) throws {
let _process = Foundation.Process()
_process.launchPath = "/usr/bin/env"
_process.arguments = arguments

let stdoutPipe = Pipe()
_process.standardOutput = stdoutPipe
let stderrPipe = Pipe()
_process.standardError = stderrPipe

stdoutPipe.output(to: stdout)
stderrPipe.output(to: stderr)

try _process.run()
_process.waitUntilExit()

let exitStatus = _process.terminationStatus
if exitStatus == 0 {
completionHandler(.success)
} else {
completionHandler(.failure(exitStatus))
}
}
}

extension Pipe {
fileprivate func output(to output: Coquille.Process.Output?) {
guard let output else { return }

fileHandleForReading.readabilityHandler = { pipe in
switch output {
case .stdout:
if #available(macOS 11, *) {
try? FileHandle.standardOutput.write(contentsOf: pipe.availableData)
} else {
FileHandle.standardOutput.write(pipe.availableData)
}
case .stderr:
if #available(macOS 11, *) {
try? FileHandle.standardError.write(contentsOf: pipe.availableData)
} else {
FileHandle.standardError.write(pipe.availableData)
}
case .handler(let handler):
// This closure is called frequently with empty data, so only pass this on if we have something.
if let string = String(data: pipe.availableData, encoding: .utf8), !string.isEmpty {
handler(string)
}
}
}
}
}
Loading

0 comments on commit e201b18

Please sign in to comment.