Skip to content
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

Support an async entry point for commands #404

Merged
merged 11 commits into from
Mar 14, 2022
75 changes: 75 additions & 0 deletions Examples/count-lines/CountLines.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import ArgumentParser
import Foundation

@main
struct CountLines: AsyncParsableCommand {
@Argument(
help: "A file to count lines in. If omitted, counts the lines of stdin.",
completion: .file(), transform: URL.init(fileURLWithPath:))
var inputFile: URL?

@Option(help: "Only count lines with this prefix.")
var prefix: String?

@Flag(help: "Include extra information in the output.")
var verbose = false
}

extension CountLines {
var fileHandle: FileHandle {
get throws {
guard let inputFile = inputFile else {
return .standardInput
}
return try FileHandle(forReadingFrom: inputFile)
}
}

func printCount(_ count: Int) {
guard verbose else {
print(count)
return
}

if let filename = inputFile?.lastPathComponent {
print("Lines in '\(filename)'", terminator: "")
} else {
print("Lines from stdin", terminator: "")
}

if let prefix = prefix {
print(", prefixed by '\(prefix)'", terminator: "")
}

print(": \(count)")
}

mutating func run() async throws {
guard #available(macOS 12, *) else {
print("'count-lines' isn't supported on this platform.")
return
}

let countAllLines = prefix == nil
let lineCount = try await fileHandle.bytes.lines.reduce(0) { count, line in
if countAllLines || line.starts(with: prefix!) {
return count + 1
} else {
return count
}
}

printCount(lineCount)
}
}
3 changes: 1 addition & 2 deletions Examples/math/main.swift → Examples/math/Math.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import ArgumentParser

@main
struct Math: ParsableCommand {
// Customize your command's help and subcommands by implementing the
// `configuration` property.
Expand Down Expand Up @@ -242,5 +243,3 @@ func customCompletion(_ s: [String]) -> [String] {
? ["aardvark", "aaaaalbert"]
: ["hello", "helicopter", "heliotrope"]
}

Math.main()
3 changes: 1 addition & 2 deletions Examples/repeat/main.swift → Examples/repeat/Repeat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import ArgumentParser

@main
struct Repeat: ParsableCommand {
@Option(help: "The number of times to repeat 'phrase'.")
var count: Int?
Expand All @@ -33,5 +34,3 @@ struct Repeat: ParsableCommand {
}
}
}

Repeat.main()
53 changes: 32 additions & 21 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.2
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this PR ends up changing the minimum supported Swift version from 5.2 to 5.5, that will be a breaking change for any consumers who support compiling with Swift < 5.5. Is the intention to ship this as part of a 2.0 release or is breaking Swift version support not considered a semver breaking change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, @jpsim! The plan is to ship this change as part of the 1.1.0 release. My understanding is that this isn't a source breaking change, since clients using older versions of Swift will just continue to get version 1.0.3. That said, I do have the wrong platform setting below — will update that. Thanks!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why we don't wrap the new stuff in #if swift(>=5.5), and in turn keep support for older Swift versions?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ffried Not to say that you shouldn't, what are your use cases where you need to support older swift versions like 5.2?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rauhul I don't have any myself. I was just wondering in case this is considered a breaking change.

Copy link
Contributor

@rauhul rauhul Feb 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage of older Swift versions like 5.1/5.2 is higher than you might think (15% of this survey's respondents).

That might be a convincing enough reason to upgrade. IMO we (the community + apple package owners) should come up with some guidelines for version support. I think NIO announced they will support only the latest 2 swift versions. (Though I might be mis-remembering).

@Lukasa what is swift-nio's policy for swift version support and do you consider dropping support for a swift version a breaking change?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are proposing to support the latest Swift version and the two versions prior. We do not consider dropping Swift versions to be a breaking change, because as @natecook1000 says, SwiftPM takes the tools version into account when resolving what version of a package will be used. Users silently get the last version that supported their Swift version.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftPM takes the tools version into account when resolving what version of a package will be used. Users silently get the last version that supported their Swift version

This is a big surprise to me since it means SwiftPM needs to check out multiple versions of a dependency in order to identify which on is the last to be compatible with the current swift version. Until today I thought SwiftPM just looked at the git tags to determine which version of a dependency to resolve.

If this is the case, then I have no objections to versioning this with 1.1.0.

@rauhul it'd be great if there were guidelines for Swift version support for Swift library authors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jpsim This seems like a good conversation to start on the swift forums, I'm 100% certain people have opinions... (for better or worse)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fun irony here is that we discussed the tools version change, but I missed that this also added a platforms stanza, and while changing the tools version isn't breaking, changing the platforms stanza very much is.

// swift-tools-version:5.5
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
Expand All @@ -14,6 +14,7 @@ import PackageDescription

var package = Package(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@natecook1000 can we take this opportunity to make the indentation 2 spaces?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sure. It should be 4 in code samples and example code and 2 everywhere else.

name: "swift-argument-parser",
platforms: [.macOS(.v10_15), .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
products: [
.library(
name: "ArgumentParser",
Expand All @@ -23,48 +24,58 @@ var package = Package(
targets: [
.target(
name: "ArgumentParser",
dependencies: ["ArgumentParserToolInfo"]),
dependencies: ["ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserTestHelpers",
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"]),
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserToolInfo",
dependencies: []),
dependencies: [],
exclude: ["CMakeLists.txt"]),

.target(
.executableTarget(
name: "roll",
dependencies: ["ArgumentParser"],
path: "Examples/roll"),
.target(
.executableTarget(
name: "math",
dependencies: ["ArgumentParser"],
path: "Examples/math"),
.target(
.executableTarget(
name: "repeat",
dependencies: ["ArgumentParser"],
path: "Examples/repeat"),

.target(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
path: "Tools/changelog-authors"),

.testTarget(
name: "ArgumentParserEndToEndTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserUnitTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserPackageManagerTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserExampleTests",
dependencies: ["ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParserTestHelpers"],
resources: [.copy("CountLinesTest.txt")]),
]
)

#if swift(>=5.2)
// Skip if < 5.2 to avoid issue with nested type synthesized 'CodingKeys'
package.targets.append(
.testTarget(
name: "ArgumentParserPackageManagerTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]))
#if swift(>=5.6) && os(macOS)
package.targets.append(contentsOf: [
.executableTarget(
name: "count-lines",
dependencies: ["ArgumentParser"],
path: "Examples/count-lines"),
.executableTarget(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
path: "Tools/changelog-authors"),
])
#endif
71 changes: 0 additions & 71 deletions Package@swift-5.5.swift

This file was deleted.

1 change: 1 addition & 0 deletions Sources/ArgumentParser/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ add_library(ArgumentParser
"Parsable Properties/Option.swift"
"Parsable Properties/OptionGroup.swift"

"Parsable Types/AsyncParsableCommand.swift"
"Parsable Types/CommandConfiguration.swift"
"Parsable Types/EnumerableFlag.swift"
"Parsable Types/ExpressibleByArgument.swift"
Expand Down
54 changes: 54 additions & 0 deletions Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

/// A type that can be executed as part of a nested tree of commands.
public protocol AsyncParsableCommand: ParsableCommand {
mutating func run() async throws
}

extension AsyncParsableCommand {
public static func main() async {
do {
var command = try parseAsRoot()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
exit(withError: error)
}
}
}

@available(
swift, deprecated: 5.6,
message: "Use @main directly on your root `AsyncParsableCommand` type.")
public protocol AsyncMainProtocol {
associatedtype Command: ParsableCommand
}

@available(swift, deprecated: 5.6)
extension AsyncMainProtocol {
public static func main() async {
do {
var command = try Command.parseAsRoot()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
Command.exit(withError: error)
}
}
}

2 changes: 1 addition & 1 deletion Sources/ArgumentParserTestHelpers/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ extension XCTest {

let outputData = output.fileHandleForReading.readDataToEndOfFile()
let outputActual = String(data: outputData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

let errorData = error.fileHandleForReading.readDataToEndOfFile()
let errorActual = String(data: errorData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down
43 changes: 43 additions & 0 deletions Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

#if os(macOS) && swift(>=5.6)

import XCTest
import ArgumentParserTestHelpers

final class CountLinesExampleTests: XCTestCase {
func testCountLines() throws {
guard #available(macOS 12, *) else { return }
let testFile = try XCTUnwrap(Bundle.module.url(forResource: "CountLinesTest", withExtension: "txt"))
try AssertExecuteCommand(command: "count-lines \(testFile.path)", expected: "20")
try AssertExecuteCommand(command: "count-lines \(testFile.path) --prefix al", expected: "4")
}

func testCountLinesHelp() throws {
guard #available(macOS 12, *) else { return }
let helpText = """
USAGE: count-lines <input-file> [--prefix <prefix>] [--verbose]

ARGUMENTS:
<input-file> A file to count lines in. If omitted, counts the
lines of stdin.

OPTIONS:
--prefix <prefix> Only count lines with this prefix.
--verbose Include extra information in the output.
-h, --help Show help information.
"""
try AssertExecuteCommand(command: "count-lines -h", expected: helpText)
}
}

#endif
Loading