///|
/// Scenario 3, end to end: from a current version and a candidate list to the
/// shortlist of versions worth testing.
///
/// The report answers one question -- *which of these candidates is worth
/// spending a test run on?* -- and then says, in its own words, **what that
/// answer does not mean**. The two halves matter equally, because "satisfies the
/// version constraint" is the only thing this library can decide, and it is not
/// the same as "safe to upgrade".
///
/// The decision has four inputs and six rules, applied in order:
///
/// 1. **the candidate parses** as a PEP 440 version (otherwise `unparsable`);
/// 2. **it is newer than the current version** (`same` / `downgrade` otherwise --
/// an equal version is not an upgrade even when the spelling differs, because
/// `1.5` and `1.5.0` are the same version);
/// 3. **it is inside the target range** (`out-of-range` otherwise);
/// 4. **the prerelease policy admits it** (`prerelease` otherwise). The policy is
/// `SpecifierSet::filter`'s, which is the interesting one: `auto` admits a
/// prerelease only when the range admits nothing else, so `1.1b1` is dropped
/// when `1.1` exists and kept when it does not. That fallback is computed over
/// the candidates that are newer and inside the range, because those are the
/// ones this report is about;
/// 5. **its `requires-python` admits the target interpreter**
/// (`requires-python` otherwise). An unreadable `requires-python` is ignored
/// rather than treated as a rejection, which is what pip does and what this
/// library's own resolver already does;
/// 6. everything else is **kept**, and the shortlist is ordered **ascending** --
/// the smallest step first is the one to test first.
///
/// ## Why the inputs are compiled in
///
/// The library depends on `moonbitlang/core` only, and that package has no
/// filesystem API, so the candidate lists are written into this file rather than
/// read from an index. They cover the same decisions the differential corpus
/// asserts case by case (`fixtures/upgrade_corpus.mbt`, replayed against
/// `packaging`), so the example and the corpus describe the same behaviour; an
/// application replaces the lists below with whatever its index client returned.
/// Everything here is deterministic: no clock, no environment, no I/O. The output
/// is byte identical on wasm, wasm-gc, js and native.
///
/// ```sh
/// moon run examples/upgrade-check --target js
/// ```
///|
/// `1.2.3@>=3.8` -> a candidate. `@` is the separator because it cannot appear in
/// a version, and a `requires-python` may contain commas and spaces but not one.
fn parse_candidate(raw : String) -> @pyversion.UpgradeCandidate {
let parts = raw.split("@").collect()
if parts.length() == 2 {
@pyversion.UpgradeCandidate::parse(
parts[0].to_owned(),
Some(parts[1].to_owned()),
)
} else {
@pyversion.UpgradeCandidate::parse(raw, None)
}
}
///|
fn parse_version(text : String) -> @pyversion.Version? {
if text == "" {
return None
}
Some(@pyversion.Version::parse(text)) catch {
_ => None
}
}
///|
fn parse_specifier(text : String) -> @pyversion.SpecifierSet? {
if text == "" {
return None
}
Some(@pyversion.SpecifierSet::parse(text)) catch {
_ => None
}
}
///|
/// The prerelease policy in the spelling `upgrade_shortlist` expects: `None` is
/// the automatic policy, `Some(true)` forces prereleases on and `Some(false)`
/// forces them off. The library takes the tri-state directly, because the
/// automatic policy and "force them on" are genuinely different answers.
fn prerelease_policy(code : String) -> Bool? {
if code == "any" {
Some(true)
} else if code == "none" {
Some(false)
} else {
None
}
}
///|
/// One scenario: everything the decision needs, plus the name the report uses.
struct Scenario {
name : String
current : String
target : String
prereleases : String
python : String
candidates : Array[String]
}
///|
/// A named target: the range, the policy and the interpreter. The scenarios below
/// show the same candidate list under all three policies, because "which
/// prereleases are worth testing" is exactly what the policy decides and it is
/// easy to get wrong.
fn scenarios() -> Array[Scenario] {
let prerelease_list : Array[String] = [
"1.0.0a1", "1.0.0b1", "1.0.0rc1", "1.0.0", "1.1.0.dev1", "1.1.0",
]
[
{
name: "patch and minor only",
current: "1.2.3",
target: ">=1.2.3,<2.0",
prereleases: "auto",
python: "3.11",
candidates: ["1.2.4", "1.3.0", "2.0.0", "1.2.2", "1.2.3"],
},
{
name: "a window with both bounds",
current: "1.0",
target: ">=1.5,<1.8",
prereleases: "auto",
python: "3.11",
candidates: ["1.4", "1.4.9", "1.5", "1.6.1", "1.7", "1.7.9", "1.8", "1.9"],
},
{
name: "prereleases, automatic policy",
current: "0.9.0",
target: ">=0.9",
prereleases: "auto",
python: "3.10",
candidates: prerelease_list,
},
{
name: "prereleases, forced on",
current: "0.9.0",
target: ">=0.9",
prereleases: "any",
python: "3.10",
candidates: prerelease_list,
},
{
name: "prereleases, forced off",
current: "0.9.0",
target: ">=0.9",
prereleases: "none",
python: "3.10",
candidates: prerelease_list,
},
{
name: "requires-python narrows it",
current: "2.3.0",
target: ">=2.3,<3.0",
prereleases: "auto",
python: "3.11",
candidates: [
"2.3.1", "2.3.2@>=3.8", "2.3.3@>=3.12", "2.4.0rc1", "2.4.0", "3.0.0",
],
},
{
name: "everything at once",
current: "1.26.0",
target: ">=1.26,<2.0",
prereleases: "auto",
python: "3.11",
candidates: [
"1.25.2", "1.26.0", "1.26.4", "1.27.0rc1", "1.27.0", "1.28.0@>=3.13", "2.0.0",
"not-a-version",
],
},
{
name: "no target range at all",
current: "1.0.0",
target: "",
prereleases: "auto",
python: "3.11",
candidates: ["0.9.0", "1.0.0", "1.1.0", "2.0.0"],
},
]
}
///|
/// Print one scenario. Every input is accounted for: the shortlist, or the reason
/// it was dropped.
fn report(scenario : Scenario) -> Unit {
println("== \{scenario.name}")
println(
" current \{scenario.current}; target \{if scenario.target == "" { "(none)" } else { scenario.target }}; prereleases \{scenario.prereleases}; CPython \{scenario.python}",
)
let current = parse_version(scenario.current)
if current is None {
println(
" UNPARSABLE_CURRENT: the current version is not a PEP 440 version",
)
println("")
return
}
let target = parse_specifier(scenario.target)
if scenario.target != "" && target is None {
println(" BAD_TARGET: the target range is not a valid specifier set")
println("")
return
}
let candidates : Array[@pyversion.UpgradeCandidate] = []
for raw in scenario.candidates {
candidates.push(parse_candidate(raw))
}
// Positional, not labelled: the four inputs are the whole point of the call.
let plan = @pyversion.upgrade_shortlist(
current.unwrap(),
target,
prerelease_policy(scenario.prereleases),
parse_version(scenario.python),
candidates,
)
println(
" \{candidates.length()} candidate(s): \{plan.shortlist.length()} in the shortlist, \{plan.dropped.length()} dropped",
)
if plan.is_empty() {
println(" shortlist: (nothing to test)")
} else {
println(
" shortlist, smallest step first: \{plan.versions().map(fn(v) { v.to_string() }).join(" ")}",
)
}
// The dropped list is printed with the rule that dropped it, in the order the
// rules ran: a report that says "rejected" alone is not actionable.
for entry in plan.dropped {
println(" dropped \{entry.0.raw}: \{entry.1.code()}")
}
println("")
}
///|
fn main {
let scenarios = scenarios()
println(
"upgrade check: which candidates are worth a test run, and what that does not mean",
)
println("")
for scenario in scenarios {
report(scenario)
}
println("== what this report does not check")
println(
" \"inside the target range, newer than what is installed\" is the only question",
)
println(
" this library answers. It does not check API or behaviour compatibility,",
)
println(
" dependency solvability, security advisories, build or test failures, or the",
)
println(
" release notes. A version on this shortlist is a candidate for a test run,",
)
println(
" not a recommendation, and a version left off it is not a statement that the",
)
println(" version is broken.")
}