- Proposal: SE-0450
- Authors: Franz Busch, Max Desiatov
- Review Manager: Mishal Shah
- Status: Implemented (Swift 6.1)
- Implementation: swiftlang/swift-package-manager#7704, swiftlang/swift-package-manager#7703, swiftlang/swift-package-manager#7702, swiftlang/swift-package-manager#7701, swiftlang/swift-package-manager#7694, swiftlang/swift-package-manager#7689, swiftlang/swift-package-manager#8178
- Review: (pitch) (review) (acceptance)
Over the past years the package ecosystem has grown tremendously in both the amount of packages and the functionality that individual packages offer. Additionally, Swift is being used in more environments such as embedded systems or Wasm. This proposal aims to give package authors a new tool to conditionalize the features they offer and the dependencies that they use.
There are various motivating use-cases where package authors might want to express configurable compilation or optional dependencies. This section is going to list a few of those use-cases.
Some packages want to make it configurable what underlying technology is used.
The Swift OpenAPIGenerator
for example is capable of running on top of URLSession
, AsyncHTTPClient
,
Hummingbird
or Vapor
. To avoid bringing all of those potential dependencies
into every adopters binary, the project has created individual repositories for
each transport. This achieves the goal of making the dependencies optional;
however, it requires users to discover those adjacent repositories and add
additional dependencies to their project.
Packages often want to cater to multiple ecosystems such as the iOS or the
server ecosystem. While most of the technologies are shared between ecosystems
there are often some platform specific behaviors/libraries that one might use.
For example, on Apple's platforms OSLog
is the canonical logging system
whereas the server ecosystem is mostly using swift-log
. However, there are
some users that prefer to use swift-log
on Apple's platforms which means
libraries and applications cannot use platform compiler conditionals.
A lot of packages are using environment variables in their Package.swift
to
configure their package. This has various reasons such as optional dependencies
or setting certain defines for local development. Using environment variables
inside Package.swift
is not officially supported and with stricter sandboxing
rules might break in the future.
Some packages want to introduce new functionality without yet committing to a stable public API. Currently, those modules and APIs are often underscored or specifically annotated. While this approach works it comes with downsides such as hiding the APIs in code completion.
This proposal introduces a new configuration for packages called package
traits. Package authors can define a set of traits in their Package.swift
that their package offers which provide a way to express conditional compilation
and optional dependencies. Furthermore, a set of default enabled traits can be
specified.
let package = Package(
name: "Example",
traits: [
"Foo",
.trait(
name: "Bar",
enabledTraits: [ // Other traits that are enabled when this trait is being enabled
"Foo",
]
)
.trait(
name: "FooBar",
enabledTraits: [
"Foo",
"Bar",
]
),
.default(enabledTraits: ["Foo"]), // Defines all the default enabled traits
],
/// ...
)
When depending on a package the default
trait is enabled. However, the enabled
traits can be customized by passing a set of enabled traits when declaring the
dependency. When specifying the enabled traits of the dependencies the
.default
trait can be passed which will enable the default trait. The below
example enables the default trait and the additional SomeTrait
of the package.
dependencies: [
.package(
url: "https://github.com/Org/SomePackage.git",
from: "1.0.0",
traits: [
.default,
"SomeTrait"
]
),
]
To disable all traits including the default trait an empty set can be passed.
dependencies: [
.package(
url: "https://github.com/Org/SomePackage.git",
from: "1.0.0",
traits: [] // All traits are disabled
),
]
Another common scenario is to enable a trait of a dependency only when a trait
of the package is enabled. The below example enables the SomeOtherTrait
when
the Foo
trait of this package is enabled.
dependencies: [
.package(
url: "https://github.com/Org/SomePackage.git",
from: "1.0.0",
traits:[
.trait("SomeOtherTrait", condition: .when(traits: ["Foo"])),
]
),
]
Conditional dependencies are specified per target and extend the current
condition
syntax which is used for specifying platform dependent dependencies.
targets: [
.target(
name: "SomeTarget",
dependencies: [
.product(
name: "SomeProduct",
package: "SomePackage",
condition: .when(traits: ["Foo"])
),
]
)
]
Lastly, code can be conditionally compiled by checking if a trait is enabled.
This can be used for both optional dependencies by surrounding the import
statements in a trait check and for regular code where you want to modify its
behaviour depending on the enabled traits.
#if Foo
import SomeDependency
#endif
func hello() {
#if Foo
Foo.hello()
#else
print("Hello")
#endif
}
This proposal extends the current PackageDescription
APIs by introducing the
following new Trait
type.
/// A struct representing a package's trait.
///
/// Traits can be used for expressing conditional compilation and optional dependencies.
public struct Trait: Hashable, ExpressibleByStringLiteral {
/// Declares the default traits for this package.
public static func `default`(enabledTraits: Set<String>) -> Self
/// The trait's canonical name.
///
/// This is used when enabling the trait or when referring to it from other modifiers in the manifest.
///
/// The following rules are enforced on trait names:
/// - The first character must be a [Unicode XID start character](https://unicode.org/reports/tr31/#Figure_Code_Point_Categories_for_Identifier_Parsing)
/// (most letters), a digit, or `_`.
/// - Subsequent characters must be a [Unicode XID continue character](https://unicode.org/reports/tr31/#Figure_Code_Point_Categories_for_Identifier_Parsing)
/// (a digit, `_`, or most letters), `-`, or `+`.
/// - `default` and `defaults` (in any letter casing combination) are not allowed as trait names to avoid confusion with default traits.
public var name: String
/// The trait's description.
///
/// Use this to explain what functionality this trait enables.
public var description: String?
/// A set of other traits of this package that this trait enables.
public var enabledTraits: Set<String>
/// Initializes a new trait.
///
/// - Parameters:
/// - name: The trait's canonical name.
/// - description: The trait's description.
/// - enabledTraits: A set of other traits of this package that this trait enables.
public init(
name: String,
description: String? = nil,
enabledTraits: Set<String> = []
)
/// Initializes a new trait.
///
/// This trait is disabled by default and enables no other trait of this package.
public init(stringLiteral value: StringLiteralType)
/// Initializes a new trait.
///
/// - Parameters:
/// - name: The trait's canonical name.
/// - description: The trait's description.
/// - enabledTraits: A set of other traits of this package that this trait enables.
public static func trait(
name: String,
description: String? = nil,
enabledTraits: Set<String> = []
) -> Trait
}
The Package
class is extended to define a set of traits:
public final class Package {
// ...
/// The set of traits of this package.
public var traits: Set<Trait>
/// Initializes a Swift package with configuration options you provide.
///
/// - Parameters:
/// - name: The name of the Swift package, or `nil` to use the package's Git URL to deduce the name.
/// - defaultLocalization: The default localization for resources.
/// - platforms: The list of supported platforms with a custom deployment target.
/// - pkgConfig: The name to use for C modules. If present, Swift Package Manager searches for a
/// `<name>.pc` file to get the additional flags required for a system target.
/// - providers: The package providers for a system target.
/// - products: The list of products that this package makes available for clients to use.
/// - traits: The set of traits of this package.
/// - dependencies: The list of package dependencies.
/// - targets: The list of targets that are part of this package.
/// - swiftLanguageVersions: The list of Swift versions with which this package is compatible.
/// - cLanguageStandard: The C language standard to use for all C targets in this package.
/// - cxxLanguageStandard: The C++ language standard to use for all C++ targets in this package.
public init(
name: String,
defaultLocalization: LanguageTag? = nil,
platforms: [SupportedPlatform]? = nil,
pkgConfig: String? = nil,
providers: [SystemPackageProvider]? = nil,
products: [Product] = [],
traits: Set<Trait> = [],
dependencies: [Dependency] = [],
targets: [Target] = [],
swiftLanguageVersions: [SwiftVersion]? = nil,
cLanguageStandard: CLanguageStandard? = nil,
cxxLanguageStandard: CXXLanguageStandard? = nil
)
}
Furthermore, a new Package.Dependency.Trait
type is introduced that can be used
to configure the traits of a dependency.
extension Package.Dependency {
/// A struct representing an enabled trait of a dependency.
public struct Trait: Hashable, Sendable, ExpressibleByStringLiteral {
/// Enables the default traits of a package.
public static let default: Self
/// A condition that limits the application of a dependencies trait.
public struct Condition: Hashable, Sendable {
/// Creates a package dependency trait condition.
///
/// - Parameter traits: The set of traits that enable the dependencies trait. If any of the traits are enabled on this package
/// the dependencies trait will be enabled.
public static func when(
traits: Set<String>
) -> Self?
}
/// The name of the enabled trait.
public var name: String
/// The condition under which the trait is enabled.
public var condition: Condition?
/// Initializes a new enabled trait.
///
/// - Parameters:
/// - name: The name of the enabled trait.
/// - condition: The condition under which the trait is enabled.
public init(
name: String,
condition: Condition? = nil
)
public init(stringLiteral value: StringLiteralType)
/// Initializes a new enabled trait.
///
/// - Parameters:
/// - name: The name of the enabled trait.
/// - condition: The condition under which the trait is enabled.
public static func trait(
name: String,
condition: Condition? = nil
) -> Trait
}
}
The dependency APIs are then extended with new variants that take a Trait
parameter:
extension Package.Dependency {
// MARK: Path
public static func package(
path: String,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
name: String,
path: String,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
// MARK: Source repository
public static func package(
url: String,
from version: Version,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
url: String,
branch: String,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
url: String,
revision: String,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
url: String,
_ range: Range<Version>,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
url: String,
_ range: ClosedRange<Version>,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
url: String,
exact version: Version,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
// MARK: Registry
public static func package(
id: String,
from version: Version,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
id: String,
exact version: Version,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
id: String,
_ range: Range<Version>,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
public static func package(
id: String,
_ range: ClosedRange<Version>,
traits: Set<Package.Dependency.Trait>
) -> Package.Dependency
}
Lastly, traits can also be used to conditionalize SwiftSettings
, CSettings
,
CXXSettings
and LinkerSettings
. For this the BuildSettingCondition
is extended.
/// Creates a build setting condition.
///
/// - Parameters:
/// - platforms: The applicable platforms for this build setting condition.
/// - configuration: The applicable build configuration for this build setting condition.
/// - traits: The applicable traits for this build setting condition.
public static func when(
platforms: [Platform]? = nil,
configuration: BuildConfiguration? = nil,
traits: Set<String>? = nil
) -> BuildSettingCondition {
precondition(!(platforms == nil && configuration == nil))
return BuildSettingCondition(platforms: platforms, config: configuration, traits: nil)
}
At this point, it is important to talk about the trait unification across the entire dependency graph. After dependency resolution the union of enabled traits per package is calculated. This is then used to determine both the enabled optional dependencies and the enabled traits for the compile time checks. Since the enabled traits of a dependency are specified on a per package level and not from the root of the tree, any combination of enabled traits must be supported. A consequence of this is that all traits should be additive. Enabling a trait should not disable functionality i.e. remove API or lead to any other SemVer-incompatible change.
Some rare use-cases may want mutally exclusive traits which are incompatible to be enabled at the same time. This should be avoided if possible because it requires the whole dependency graph to coordinate on what trait to enable. In the rare case where mutually exclusive traits are used consider adding a compiler error to detect this during build time.
#if Trait1 && Trait2
#error("Trait1 and Trait2 are mutually exclusive")
#endif
A few options to avoid mutually exclusive traits:
- Separate the code into multiple packages
- Choose one trait over the other when possible
- Use platform checks
#if os(Windows)
when possible
Default traits allow package authors to define a set of traits that they think cater to the majority use-cases of the package. When choosing the initial default traits or adding a new default trait it is important to consider that removing a default trait is a SemVer-incompatible change since it can potentially remove APIs.
When executing one of swift test/build/run
options can be passed to control which
traits for the root package are enabled:
--traits
TRAITS: Enables the passed traits of the package. Multiple traits can be specified by providing a comma separated list e.g.--traits Trait1,Trait2
.--enable-all-traits
: Enables all traits of the package.--disable-default-traits
: Disables all default traits of the package.
Trait names are namespaced per package; hence, multiple packages can define the same trait names. Moreover, it is an expected scenario that multiple packages define the same trait name and conditionally enable the equivalent named trait in their dependencies.
To prevent abuse, limit the complexity and make sure it integrates with the compiler a few limitations are imposed.
Other ecosystems have shown that a large number of traits can have significant impact on registries and dependency managers. To avoid such a scenario an initial maximum number of 300 defined traits per package is imposed. This can be revisited later once traits have been used in the ecosystem extensively.
Since traits can show up both in the Package.swift
and in source code when
checking if a trait is enabled, the allowed characters for a trait name are
restricted to legal Swift
identifier.
Additional, the following rules are enforced on trait names:
default
anddefaults
(in any letter casing combination) are not allowed as trait names to avoid confusion with default traits.
The swift package dump-package
command will include information of the trait configuration for the
package and the dependencies it uses in the JSON output.
There is no impact on existing packages. Any package can start adopting package traits but in doing so must not move existing API behind new traits. Even if the trait is a enabled by default any consumer might have already disabled all default traits; hence, moving API behind a new default trait could potentially break them.
The initial impact on other build systems should be minimal. Exisiting packages
must not move exisiting APIs behind a trait. For new APIs that are guarded by a
trait a build system must pass the correct SWIFT_ACTIVE_COMPILATION_CONDITIONS
when building the modules of the package. Other build systems might want to
consider to expose a way to model traits in their target description.
Traits are used to conditionally compile code. When building documentation the symbol graph extracter will only see the code that is actually compiled. Systems that produce documentation for packages should default to building with all traits enabled so that all API documentation is visible.
The implementation to this proposal only considers traits after the dependency resolution when constructing the module graph. This is inline with how platform specific dependencies are currently handled. In the future, both platform specific dependencies and traits can be taken into consideration during dependency resolution to avoid fetching an optional dependency that is not enabled by a trait. Changing this doesn't require a Swift evolution proposal since it is just an implementation detail of how dependency resolution currently works.
The current proposal passes enabled traits via custom defines to the compiler
and code can check it using regular define checks (#if DEFINE
). In the future,
we can extend the compiler to make it aware of package traits to allows syntax
like #if trait(FOO)
or implement an extensible configuration macro similar to
Rust's cfg
macro.
Since trait unification is done for every package in the graph during build time the information which module enabled which trait of its dependencies is lost. As a consequence it might be that a package accidentally uses an API from a dependency which is guarded by a trait that another package in the graph has enabled. Since the traits that any one package in the graph enables on its dependencies are not considered part of the semantic version, it can happen that disabling a trait could result in breaking a build. In the future, we could integrate trait checking further into the compiler where it understands if an API is only available if a certain trait is set.
Cargo currently treats this similar and doesn't consider disabling a cargo feature a breaking change.
A future evolution could allow to mark traits as default depending on the
platform that the package is build on. This would allow packages such as the
swift-openapi-generator
to default the used transport depending on the
platform which makes it even easier to offer users the best out of box
experience. This is left as a future evolution since it intersects interestingly
with the future direction "Consider traits during dependency resolution". If
default traits depend on the target build platform then this must be an input to
the dependency resolution.
One use-case where mutually exclusive traits are used is to configure the behaviour of a single package globally. This means that only the final executable is deciding what trait to enable. In a future proposal, we could introduce a new setting for marking a trait as globally configured and check that only executable targets are enabling such a trait.
Since a single package should support building with any combination of traits,
it would be helpful to offer package authors tooling to build and test all
combinations. A new option --all-trait-combinations
could be added to
swift test/build/run
make testing all combinations easy as possible.
If the compiler gains knowledge about package traits in the future, we could extract information if a public API is guarded by a trait and surface this in the documentation.
During the implementation and writing of the proposal different names for package traits have been considered such as:
- Package features
- Package optional features
- Package options
- Package parameters
- Package flags
- Package configuration
A lot of the other considered names have other meanings in the language already.
For example feature
is already used in expressing compiler feature via
enable[Upcoming|Experimental]Feature
and the hasFeature
check.
Package traits are also consistent with the "traits" concept in the
swift-testing
library.
During the investigation how to solve the optional dependency problem @_spi
was considered; however, the problem with @_spi
is that the code is still
compiled and present in the final binary. Optional dependencies can't work like
this since the symbols potentially aren't present during compile time.
Traits could be expressed via an enum in the package description which would
make sure that they are statically typed. This proposal decided to use String
based trait names instead to align with the other definitions inside the package
description such as targets
or products
.
Other dependency managers have similar features to control optional dependencies and conditional compilation.
- Cargo has optional features that allow conditional compilation and optional dependencies.
- Maven has optional dependencies.
- Gradle has feature variants that allow conditional compilation and optional dependencies.
- Go has build constraints which can conditionally include a file.
- pip dependencies can have optional dependencies and extras.
- Hatch offers optional dependencies and features.