How to create asynchronous Swift Command-Line Tool with AsyncParsableCommand?

January 29, 20225 min read#iOS, #Swift

The Problem

SwiftArgumentParser package provides a great infrastructure to write command line interface (CLI) tools.

Normally, CLI tools process tasks in synchronous ways, one command after the others. But sometimes, when interacting with long running task, such as communicating with remote servers, we also want to give users of our CLI tools feedback about the progress, and any errors that could happen during processing.

Currently, there are different way create asynchronous CLI commands using Swift Argument Parsers such as:

These APIs might work great, but it is quite complicated to integrate into command line interfaces.

Following code is an example of a simple CLI command to fetch info about an user from GitHub API using octokit.swift library.

@main
struct FetchUser: ParsableCommand {

  func run() throws {
    let semaphore = DispatchSemaphore(value: 0)

    Octokit().user(name: "octocat") { response in
      switch response {
      case .success(let user):
        dump(user)
      case .failure(let error):
        dump(error)
      }

      semaphore.signal()
    }

    semaphore.wait()
  }
}

The Solution

A new Concurrency feature has been introduced into Swift langauge recently. This feature simplifies writing asynchronous code by using async/await keywords

We can easily transform a completion-based API to async/await-based API using withCheckedContinuation and withCheckedThrowingContinuation

Convert completion-based APIs to async/await

In this example, we are using Octokit to fetch information of a GitHub’s user.

We can easily convert the above completion-based API into async/await-based API using withCheckedThrowingContinuation

func fetchUser() async throws -> User {
  return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<User, Error>) in
    Octokit().user(name: "octocat") { response in
      switch response {
      case .success(let user):
        continuation.resume(returning: user)
      case .failure(let error):
        continuation.resume(throwing: error)
      }
    }
  }
}

Integration with AsyncParsableCommand

SwiftArgumentParser package currently supports async/await integration in a separated branch

This branch introduces some additional type to expose async/await API, such as AsyncMain, AsyncParseableCommand.

import ArgumentParser
import OctoKit

@main enum Main: AsyncMain {
  typealias Command = FetchUser
}
 
struct FetchUser: AsyncParsableCommand {
  func run() async throws {
    let result = try await fetchUser()
    dump(result)
  }
  
  func fetchUser() async throws -> User {
    return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<User, Error>) in
      Octokit().user(name: "octocat") { response in
        switch response {
        case .success(let user):
          continuation.resume(returning: user)
        case .failure(let error):
          continuation.resume(throwing: error)
        }            
      }
    }
  }
}

Now, when running swift run fetch-user, you should see the following result in your terminal.

➜  swift run fetch-user
[3/3] Build complete!
▿ OctoKit.User #0
  - id: 583231
  ▿ login: Optional("octocat")
    - some: "octocat"
  ▿ avatarURL: Optional("https://avatars.githubusercontent.com/u/583231?v=4")
    - some: "https://avatars.githubusercontent.com/u/583231?v=4"
  ▿ gravatarID: Optional("")
    - some: ""
  ▿ type: Optional("User")
    - some: "User"
  ▿ name: Optional("The Octocat")
    - some: "The Octocat"
  ▿ company: Optional("@github")
    - some: "@github"
  ▿ blog: Optional("https://github.blog")
    - some: "https://github.blog"
  ▿ location: Optional("San Francisco")
    - some: "San Francisco"
  - email: nil
  ▿ numberOfPublicRepos: Optional(8)
    - some: 8
  ▿ numberOfPublicGists: Optional(8)
    - some: 8
  - numberOfPrivateRepos: nil
  ▿ nodeID: Optional("MDQ6VXNlcjU4MzIzMQ==")
    - some: "MDQ6VXNlcjU4MzIzMQ=="
  ▿ url: Optional("https://api.github.com/users/octocat")
    - some: "https://api.github.com/users/octocat"
  ▿ htmlURL: Optional("https://github.com/octocat")
    - some: "https://github.com/octocat"
  ▿ followersURL: Optional("https://api.github.com/users/octocat/followers")
    - some: "https://api.github.com/users/octocat/followers"
  ▿ followingURL: Optional("https://api.github.com/users/octocat/following{/other_user}")
    - some: "https://api.github.com/users/octocat/following{/other_user}"
  ▿ gistsURL: Optional("https://api.github.com/users/octocat/gists{/gist_id}")
    - some: "https://api.github.com/users/octocat/gists{/gist_id}"
  ▿ starredURL: Optional("https://api.github.com/users/octocat/starred{/owner}{/repo}")
    - some: "https://api.github.com/users/octocat/starred{/owner}{/repo}"
  ▿ subscriptionsURL: Optional("https://api.github.com/users/octocat/subscriptions")
    - some: "https://api.github.com/users/octocat/subscriptions"
  ▿ reposURL: Optional("https://api.github.com/users/octocat/repos")
    - some: "https://api.github.com/users/octocat/repos"
  ▿ eventsURL: Optional("https://api.github.com/users/octocat/events{/privacy}")
    - some: "https://api.github.com/users/octocat/events{/privacy}"
  ▿ receivedEventsURL: Optional("https://api.github.com/users/octocat/received_events")
    - some: "https://api.github.com/users/octocat/received_events"
  ▿ siteAdmin: Optional(false)
    - some: false
  - hireable: nil
  - bio: nil
  - twitterUsername: nil
  ▿ numberOfFollowers: Optional(4690)
    - some: 4690
  ▿ numberOfFollowing: Optional(9)
    - some: 9
  ▿ createdAt: Optional(2011-01-25 18:44:36 +0000)
    ▿ some: 2011-01-25 18:44:36 +0000
      - timeIntervalSinceReferenceDate: 317673876.0
  ▿ updatedAt: Optional(2022-01-24 15:08:43 +0000)
    ▿ some: 2022-01-24 15:08:43 +0000
      - timeIntervalSinceReferenceDate: 664729723.0
  - numberOfPrivateGists: nil
  - numberOfOwnPrivateRepos: nil
  - amountDiskUsage: nil
  - numberOfCollaborators: nil
  - twoFactorAuthenticationEnabled: nil
  - subscriptionPlan: nil

The asynchronous process to communicate with GitHub API server to fetch user info can be done with a simple call let result = try await fetchUser() . Isn’t it simple?

Conclusion

In this article, I have introduced you to a new in-coming feature of SwiftArgumentParser which simplifies writing asynchronous CLI command a lot. By using async/await keywords, and converting our main entry command into AsyncMain, we can call our asynchronous commands in sequences with simple APIs.

Hopefully, the async branch will be merged into the main branch of swift-argument-parser soon. But for now, we can already use this feature by pointing our Package.swift directly to the async branch.


Profile picture

Personal blog by An Tran. I'm focusing on creating useful mobile apps.
#Swift #Kotlin #Mobile #MachineLearning #Minimalist


© An Tran - 2024