I can provide a case study where the package traits really improve both the ergonomics and safety of the package management.
I have a C library dependency that is currently modelled as a system library dependency. Like many standard C libraries out there, this library can be compiled with various optional features, often depending on the presence of other libraries. When I depend on this library I require a specific piece of optional functionality to be present through my dependency. But, there's no way to model this through a system dependency. And so, it's entirely vulnerable to the environment in which the library was compiled with no way to discover the mismatch until runtime, hopefully through some test. This is far too late in the process to find this problem.
It's possible to vendor this dependency as a local target within my package. I can set it up so that the C defines are set to force the optional functionality, and provide the system library dependency to match that. But then there are some problems:
- Everyone has their own private copy of the library owning the maintenance burden of updating it themselves
- Library code is copied into each repo using something like
git submodule, git subree, or worse, a direct copy that's not as trivial to sync with the upstream library code
If I want to make a version of this library that is shared with the rest of the Swift community I need to take into consideration that the package will need to support different needs in terms of optional functionality. The package can have a different shape for different dependencies. So, how do I provide switches to change its shape today?
The only viable option for controlling a package's shape is through environment variables at the moment. Here's a snippet of a Package.swift that allows its shape to be changed by environment variables:
var libTarget: Target = .target(
name: "libfoo",
dependencies: [],
cSettings: []
)
let package = Package(name: "LibFoo", ...)
// This is a list of system libraries that can be enabled in the libfoo to add extra functionality using the specified environment variable in swift build
let libs = [
(envVar: "LIBFOO_ENABLE_Z", define: "HAVE_LIBZ", libName: "zlib", moduleLoc: "swiftpm/zlib", pkgConfig: "zlib", aptProvider: "zlib1g-dev"),
...
]
for lib in libs {
// Until we have traits, we use environment variables here to set which system libraries we want to use
if let _ = ProcessInfo.processInfo.environment[lib.envVar] {
libTarget.dependencies.append(.target(name: lib.libName))
libTarget.cSettings!.append(CSetting.define(lib.define, to: "1"))
package.targets.append(
.systemLibrary(
name: lib.libName,
path: lib.moduleLoc,
pkgConfig: lib.pkgConfig,
providers: [.apt([lib.aptProvider])]
)
)
}
}
While this gets us closer to a sharable SwiftPM for this library there's still a few major problems:
- The user running
swift build must know the environment variables to control the shapes of all of the dependencies
- The user must know the transitive requirements of all of the packages for their dependencies
- SwiftPM doesn't know anything about these environment variables, the build graph could become mismatched between invocations depending on the state of the environment variables
So, how can this look if we use package traits to control the package's shape?
// This is a list of traits that can be enabled in libfoo to add extra functionality using the specified trait coming from either a system library or an SDK built-in.
// Dependent packages can enable the traits that they need and/or the `swift build` can enable one or more of them.
let traits = [
(name: "Z", define: "HAVE_LIBZ", libName: "zlib", moduleLoc: "swiftpm/zlib", pkgConfig: "zlib", aptProvider: "zlib1g-dev"),
...
]
let pkgTraits = Set<Trait>(traits.map { .trait(name: $0.name) })
let sysLibs: [PackageDescription.Target] = traits.map { trait in
.systemLibrary(
name: trait.libName,
path: trait.moduleLoc,
pkgConfig: trait.pkgConfig,
providers: [.apt([trait.aptProvider])]
)
}
let sysLibDeps: [PackageDescription.Target.Dependency] = traits.map { trait in
.target(
name: trait.libName,
condition: .when(platforms: trait.sysLibPlatforms, traits: [trait.name])
)
}
let cSettings: [PackageDescription.CSetting] = traits.map { trait in
.define(trait.define, to: "1", .when(traits: [trait.name]))
}
let package = Package(
name: "LibFoo",
traits: pkgTraits,
targets: [
.target(
name: "libfoo",
dependencies: sysLibDeps,
cSettings: cSettings,
),
Now anyone can depend on libfoo like this, requiring a particular trait:
Package(
name: "mytool",
dependencies: .package(url: "https://github.com/bar/foo.git", from: "1.0.0", traits: ["Z"]) // We need the zlib trait because there are zlib compressed data streams that foo will be handling
)
And other dependents can require their own traits of the libfoo also. SwiftPM will be able to have enough knowledge to assemble an appropriate build graph at all times.
The ergonomics of being able to add declarative .when() conditions with the traits on the C settings and dependencies can also make the trait example even simpler, because they could have been statically coded into the targets instead of mapped out with the let statements and mapping. In the environment example above this would not have been possible because of the lack of the ability to tie a when condition to an environment variable.
Finally, I can share this C library with others in the Swift ecosystem!
Overall, I see the traits as a big benefit to the Swift ecosystem not only because it can be adapted to C libraries like in this case study, but also because it allows Swift packages to adjust their shape too based on a variety of factors, such as code footprint (using defines), and controlling optional dependencies.