SwiftPM + canImport

Hey guys!

I was very excited by the time canImport landed, it's a great feature, but it doesn't help much in SwiftPM environment.

Here's an example:

let package = Package(
    name: "X",
    dependencies: [
        .package(url: "file.git", .branch("master")),
        .package(url: "stream.git", .branch("master")),
        .package(url: "crypto.git", .branch("master"))
    ],
    ...
)

It would be great to avoid heavy crypto package or even a simple stream if we only need file.git, but if they're available somewhere in the graph we would get extra APIs from the file.git:

file+extensions.swift:

@if canImport(Stream) {
    import Stream

    extension File {
        public func open() -> Stream { ... }
    }
}

@if canImport(Crypto) {
    import Crypto

    extension File {
        public func sha() -> Sha { ... }
    }
}

I think the easier way to help the build system is to add optionalDependencies: to the .target:

let package = Package(
    name: "File",
    ...
    optionalDependencies: [
        .package(url: "stream.git", .branch("master")),
        .package(url: "crypto.git", .branch("master"))
    ],
    targets: [
        .target(
            name: "File",
            dependencies: [],
            optionalDependencies: ["Stream", "Crypto"])
    ]
)
2 Likes

I think adding optional dependencies is an excellent idea and I wholeheartedly support it!

I think one major question is how will the package manager decide if an optional dependency should be cloned or not.

By default it shouldn't. It should be used as dependency only if it's available somewhere in the graph (if it's being used as non-optional dependency somewhere)

I see, that makes sense I think. There are several other open questions in my mind, for e.g.:

  • What happens if there is a non-optional dependency in the graph but they're not satisfiable?
  • What happenes when a package with optional dependencies is a root package?
  • Optional dependencies can also unnecessarily constrain a particular dependency in the graph. Maybe this isn't an actual issue though.

There are probably more open questions that I haven't thought of yet. Feel free to work on a draft proposal to flesh out the details!

The answer to all of this: nothing happens. It doesn't add any constrain. We only use the dependency if we can satisfy the requirements using the main graph.

Well, actually we could treat them as non-optional in this case because it's a dev-only story I think.

Will do.

Is this possible nowadays?

I'm trying to conditionally add code into a SPM target (A) only when that's a dependency of another target (B) that imports another dependency (C).

in target A
#if canImport(C)
...

target B imports A and C.

I thought canImport would work for this but it doesn't seem to behave as I expect, at least from Xcode. Sometimes the code is compiled when compiling the schema for A, even tho C is not imported at all. Other times compiling B doesn't work, even tho it imports both other targets.

I don't think canImport works for this, since it is based on whether or not there happens to be a module C in the search path. If you don't explicitly depend on it, that will not be deterministic as C can be build in parallel or even after A. It can also be present from previous builds if you are building incrementally.

1 Like

That’s what it looked like ^^ thanks for confirming it!

I guess there is no way to get something like this then?

Basically I wish I could do the same Apple does where you need to import SwiftUI and MapKit to get access to map view, that but with my own (or 3rd party) packages.

This is because otherwise you always need to make an extra package that brings both together. This is fine for 1 but when you modularise your app things like this arise a bunch.

Sounds like you want cross-import overlays, which have been pitched but are not a finished, publicly available feature:

Oh that seems to be what I was looking for. In my mind the can import technique makes more sense so you don’t have to make more packages but maybe it can’t work. Anyway I will look into that other thread.
cheers

There’s a few issues here that I feel are worth discussing, as the status quo runs straight into them.

  1. A package should be able to take advantage of an optional package if it already exists. Despite being optional, it would need to have version restrictions to avoid unpredictably breaking. In the event of a version that doesn’t match the optional dependency, it would be best for the optional functionality to be disabled rather than failing outright.
  2. A package may want a dependency purely to use optional functionality in another package (that is, without directly using anything in the optional dependency). It may make sense, therefore, to instead specify that the optional functionality is required. This could be accomplished by assigning optional dependencies to a “flag” that can be passed when depending on a product. Using a separate product would be ideal, but prevents internal declarations from being used.
  3. A package may need at least one of a set of “optional” dependencies. Swift Package Manager could implement this deterministically by choosing the first compatible dependency in the list if none of them already exist (most notably when it is the root package).
  4. A package may have mutually-exclusive optional dependencies that produce the same interface: for instance, the same protocol implementation may be provided using different packages, such that having multiple would cause compilation to fail. This could be resolved deterministically by choosing the first present dependency in the list, similar to #3.

For the purpose of semantic versioning, altering dependencies is considered a patch-level change. This means that a package must be able to change dependencies without breaking any consumers of said package (that is, no transient dependencies should be relied upon). To my mind, this makes #2 and #3 critical.

1 Like