Lesson 09 / 12
The Package Manager
Measuring the intersection of the version ranges two packages ask for the same dependency, a local package replacing a remote one, and the union of package products.
Published Updated
In this lesson
- Measures the intersection of the version ranges two packages ask for the same dependency
- Shows why disjoint ranges are rejected
- Measures that a local package replaces the remote package and that package products merge
- Shows that the resolver picks the highest version from the intersection and that the rule does not count the chosen version
Contents
The previous lesson measured the meeting of local files and keys. An app is not made only of its own files; it takes code that others have written as a package. The platform’s package manager reads the dependency list of each package, downloads these dependencies and decides which version of each is to be used. The decision gets difficult at one point: two packages ask for the same third package, and each accepts different versions of it.
The measurement axis stays the same: when two definitions meet, how is it resolved? In this lesson the meeting definitions are the version constraints of packages. A version constraint describes a set of accepted versions; when two constraints meet, the classifier says which resolution the result falls into.
The Classifier
// Meeting.swift — classifies how two definitions are resolved when they meet
struct Meeting {
let label: String
let first: Set<String>
let second: Set<String>
let result: Set<String>?
init(_ label: String, first: Set<String>, second: Set<String>, result: Set<String>?) {
self.label = label
self.first = first
self.second = second
self.result = result
}
var resolution: String {
if first.isEmpty || second.isEmpty
|| first.isSubset(of: second) || second.isSubset(of: first) {
return "out-of-scope"
}
guard let result, !result.isEmpty else { return "rejection" }
if result == first.union(second) { return "union" }
if result == first.intersection(second) { return "intersection" }
if result == first || result == second { return "precedence" }
return "off-rule"
}
static func list(_ values: Set<String>) -> String {
values.isEmpty ? "empty" : values.sorted().joined(separator: ",")
}
func report() {
let outcome = result.map(Meeting.list) ?? "none"
print("\(label): first \(Meeting.list(first)) | second \(Meeting.list(second)) | result \(outcome) -> \(resolution)")
}
}
func summarize(_ meetings: [Meeting]) {
for resolution in ["precedence", "intersection", "union", "rejection"] {
let count = meetings.filter { $0.resolution == resolution }.count
print("\(resolution): \(count)")
}
}
This lesson needs one more small helper to build version sets. A constraint consists of a lower bound and an upper bound; the lower bound is inclusive and the upper bound is exclusive. The helper splits a version text into numbers, compares two versions in order and filters the versions a constraint accepts out of the available versions. The file contains only definitions and has no output.
// Requirement.swift — counts the versions a version constraint accepts
func versionKey(_ text: String) -> [Int] {
text.split(separator: ".").compactMap { Int($0) }
}
func isBefore(_ left: String, _ right: String) -> Bool {
versionKey(left).lexicographicallyPrecedes(versionKey(right))
}
struct Requirement {
let from: String
let upTo: String
func accepts(_ version: String) -> Bool {
!isBefore(version, from) && isBefore(version, upTo)
}
}
func accepted(_ requirement: Requirement, among available: [String]) -> Set<String> {
Set(available.filter { requirement.accepts($0) })
}
func rangeMeeting(_ label: String, _ first: Requirement, _ second: Requirement, available: [String]) -> Meeting {
let left = accepted(first, among: available)
let right = accepted(second, among: available)
return Meeting(label, first: left, second: right, result: left.intersection(right))
}
Version Ranges
Packages are numbered with semantic versioning: three numbers separated by dots. The first number increases on an incompatible change, the second on a compatible new feature, the third on a fix. When a package wants another package, it usually writes a version range: “greater than or equal to one, less than two”. The upper bound stops before the version in which the incompatible change arrives.
The package definition is written in the Package.swift file. Because the file uses the platform’s package description framework, it stands only as source. The dependency line carries an address and a range.
import PackageDescription
let package = Package(
name: "Formatter",
dependencies: [
.package(url: "https://example.com/parser.git", "1.0.0"..<"2.0.0")
],
targets: [
.target(name: "Formatter", dependencies: [.product(name: "Parser", package: "parser")])
]
)
This file produces no measurement result. A second package asks for the same dependency with the range "1.2.0"..<"3.0.0". To measure the two, we take the versions the package has published as a fixed list: six versions between 1.0.0 and 2.1.0. Each range is filtered from this list, and the meeting of the two sets is given to the classifier. First two overlapping ranges, then two disjoint ranges.
// main.swift — the version ranges two packages ask for the same dependency
let published = ["1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0.0", "2.1.0"]
let overlapping = rangeMeeting(
"overlapping",
Requirement(from: "1.0.0", upTo: "2.0.0"),
Requirement(from: "1.2.0", upTo: "3.0.0"),
available: published
)
let disjoint = rangeMeeting(
"disjoint",
Requirement(from: "1.0.0", upTo: "1.2.0"),
Requirement(from: "2.0.0", upTo: "3.0.0"),
available: published
)
overlapping.report()
disjoint.report()
overlapping: first 1.0.0,1.1.0,1.2.0,1.3.0 | second 1.2.0,1.3.0,2.0.0,2.1.0 | result 1.2.0,1.3.0 -> intersection disjoint: first 1.0.0,1.1.0 | second 2.0.0,2.1.0 | result empty -> rejection
DE9. The measurement was made for a range form with an inclusive lower bound and an exclusive upper bound. A constraint pinned to a single version and a constraint carrying only a lower bound were not measured. The version list is limited to the six published versions.
In the first meeting the versions common to the two ranges are 1.2.0 and 1.3.0: the resolution is intersection. In the second meeting there is no common version: the result is empty and the resolution is rejection. In this case the platform’s package manager stops dependency resolution and reports which two constraints conflict.
Rejection is not an error but a piece of information. There are two conflicting constraints in the dependency list, and one of the two must change. The package manager cannot choose which one to loosen; that decision belongs to the package author.
The Local Package
During development it is possible to use a dependency not with its published version but with a directory on disk. Such a local package replaces the remote package of the same name. The platform chooses the local one; it never downloads the remote version. To change the source of a dependency and see the result immediately, there is no need to publish a new version.
The model builds this with a selection function: it returns the local source if there is one, otherwise the remote source. We measure for two packages: one also exists locally, the other only remotely.
// main.swift — a local package replaces the remote package of the same name
func chooseSource(remote: String, local: String?) -> String {
local ?? remote
}
func sourceMeeting(_ label: String, local: String?) -> Meeting {
let chosen = chooseSource(remote: "remote", local: local)
return Meeting(label, first: ["remote"], second: local.map { [$0] } ?? [], result: [chosen])
}
let sourceMeetings = [
sourceMeeting("parser-package", local: "local"),
sourceMeeting("lexer-package", local: nil)
]
for meeting in sourceMeetings { meeting.report() }
parser-package: first remote | second local | result local -> precedence lexer-package: first remote | second empty | result remote -> out-of-scope
When a local source was defined, the two sources met and the local one won: precedence. When none was defined, the second definition was empty and there was no meeting. When the same name exists in two sources, the constraints of the remote version are also disabled; the local package is accepted whatever version it is at. For this reason a forgotten local package can lead to constraints being ignored unexpectedly.
DE10. The measurement compared two kinds of source, local and remote. The case where two different remote addresses carry the same package name was not measured.
Package Products
A package declares not only its code but also linkable outputs called products: a library, an executable. The app target chooses the products to link by name. The products of two packages form a single set of usable products for the app. If both packages offer a product name, the set does not carry that name twice.
The model takes the union of the product sets of the two packages. The first package offers the parser and tokens products, the second package the formatter and tokens products.
// main.swift — the products of two packages merge into a single set
func availableProducts(_ first: Set<String>, _ second: Set<String>) -> Set<String> {
first.union(second)
}
let parserProducts: Set<String> = ["parser", "tokens"]
let formatterProducts: Set<String> = ["formatter", "tokens"]
let productMeeting = Meeting(
"products",
first: parserProducts,
second: formatterProducts,
result: availableProducts(parserProducts, formatterProducts)
)
productMeeting.report()
products: first parser,tokens | second formatter,tokens | result formatter,parser,tokens -> union
Three products came out and the resolution is union. The shared tokens name caused no collision, because in this measurement a product name is an identity and the set does not count the same identity twice. In a real project, two separate packages having two different products with the same name is a different problem; the model does not distinguish this case.
DE11. The measurement treated the product name as an identity. The collision of two different products carrying the same name and a product being offered only on a particular platform were not measured.
The Bounding Measurement
In the first measurement the intersection gave two versions: 1.2.0 and 1.3.0. The app, however, is built with a single version. The resolver picks one version from the intersection, and usually picks the highest; the chosen version is written to a lock file and reused in later resolutions. For the same meeting, the result is given once as the accepted set and once as the chosen version.
// main.swift — the resolver picks the highest version from the intersection
func highest(_ versions: Set<String>) -> String {
versions.max { isBefore($0, $1) } ?? "none"
}
let catalog = ["1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0.0", "2.1.0"]
let firstRange = Requirement(from: "1.0.0", upTo: "2.0.0")
let secondRange = Requirement(from: "1.2.0", upTo: "3.0.0")
let leftSet = accepted(firstRange, among: catalog)
let rightSet = accepted(secondRange, among: catalog)
let commonSet = leftSet.intersection(rightSet)
let acceptedSet = Meeting("accepted-set", first: leftSet, second: rightSet, result: commonSet)
let chosenVersion = Meeting("chosen-version", first: leftSet, second: rightSet, result: [highest(commonSet)])
acceptedSet.report()
chosenVersion.report()
accepted-set: first 1.0.0,1.1.0,1.2.0,1.3.0 | second 1.2.0,1.3.0,2.0.0,2.1.0 | result 1.2.0,1.3.0 -> intersection chosen-version: first 1.0.0,1.1.0,1.2.0,1.3.0 | second 1.2.0,1.3.0,2.0.0,2.1.0 | result 1.3.0 -> off-rule
When the accepted set was given, the resolution was intersection; when the single version the resolver chose was given, the classifier wrote off-rule. A single version is neither the union of the two sets, nor their intersection, nor either of them itself. The rule describes only the accepted set; which element to choose from that set is a separate decision.
The practical consequence: even when the constraints look resolvable, it is not the constraints but the resolver’s selection rule and the lock file that determine which version the app will be built with. A new resolution made without a lock file can pick a higher version; the constraints do not forbid this, because the chosen version is still inside the intersection.
DE12. The model always took the selection as the highest version. The case where the resolver keeps a version in the lock file was not measured.
Summary
- The version ranges two packages ask for the same dependency are resolved by the intersection of the accepted version sets.
- Disjoint ranges with no common version are rejected and resolution stops.
- A local package replaces the remote package of the same name; if there is no local source, no meeting arises.
- The products of two packages merge in the set of products the app can use.
- This lesson’s resolution: intersection. The rule counts the accepted set, not the single version the resolver chooses.
Next Step
In this lesson the constraints of packages met and a common set of versions came out. After the app is built it has to run somewhere: during development this place is mostly the simulator, but the simulator does not accept everything a physical device accepts. The next lesson measures the meeting of the capabilities the app requires and the capabilities the simulator offers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.