Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ served it.
and uv do.** A final release in range is still preferred. Some resolutions that used to fail or
backtrack now pick a pre-release or a newer version instead.

### Changed

- **An extra a version does not declare is now ignored, as pip and uv do, instead of excluding
that version.** A resolution can move to a newer version, and a misspelled extra no longer
fails. Each ignored extra is reported in the new `Resolution.MissingExtras` field
(`MissingExtra`, `Requester`).

## [0.11.0] - 2026-09-18

### Breaking
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Phase 3.
> **Status: implemented end to end.** `pypirsf/`, `index/`, `pep440set/`,
> `candidate/`, `provider/` and `resolver/` all carry code. `resolver.Resolve`
> is the entry point; keep its exported surface to `Resolve`, `Options`,
> `Resolution` and `ResolutionError`.
> `Resolution`, `ResolutionError`, `MissingExtra` and `Requester`.
>
> ⚠️ `resolver` is **no longer the only package Package Manager imports**. PPM
> implements its own `MetadataIndex` (`src/pyindex`) and imports `index/` for the
Expand Down
120 changes: 107 additions & 13 deletions provider/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"slices"
"strconv"
"strings"

"github.com/posit-dev/go-pubgrub/solver"
"github.com/posit-dev/go-pyresolver/index"
Expand Down Expand Up @@ -89,6 +90,7 @@ func (p *Provider) rootDependencies() ([]dependency, error) {
// aborts the resolve rather than excluding anything.
return nil, fmt.Errorf("provider: the requested requirements cannot be resolved: %s", reason)
}
p.recordExtraRequests(Root(), version.Version{}, expanded)
return append(deps, expanded...), nil
}

Expand Down Expand Up @@ -182,22 +184,20 @@ func (p *Provider) dependenciesFrom(
deps = append(deps, pyDep)
}
} else {
// An extra nobody declared must fail loudly. Without this check
// pkg[tests], where the extra is spelled test, resolves happily and
// installs nothing extra -- which looks like success. Reporting it as
// "no candidate version" is what lets the solver explain it through
// the derivation graph for free.
if !slices.Contains(meta.ProvidesExtra, pkg.Extra) {
return nil, fmt.Sprintf("it does not provide the extra %q", pkg.Extra), nil
// An extra nobody declared is ignored, as pip and uv do, rather than
// excluding the version. It is reported in Resolution.MissingExtras.
if slices.Contains(meta.ProvidesExtra, pkg.Extra) {
active = []string{pkg.Extra}
reqs = extraOnly(meta.RequiresDist, p.opts.Environment, active)
} else {
reqs = nil
p.recordUndeclaredExtra(pkg.Name, v, pkg.Extra)
}
active = []string{pkg.Extra}

// The same-version link. Without it the extra could resolve to a
// version other than the base package it is an extra OF, and the
// installed set would be incoherent.
// The same-version link, whether or not the extra is declared. Without
// it the extra could resolve to a version other than the base package
// it is an extra OF, and the installed set would be incoherent.
deps = append(deps, dependency{Package: Project(pkg.Name), Allowed: pep440set.Exactly(v)})

reqs = extraOnly(meta.RequiresDist, p.opts.Environment, active)
}

expanded, reason, err := expandRequirements(reqs, p.opts.Environment, active)
Expand All @@ -207,6 +207,11 @@ func (p *Provider) dependenciesFrom(
if reason != "" {
return nil, reason, nil
}
// pkg itself, not Project(pkg.Name): when pkg is an extra, the request came
// from THAT extra, not from the base with no extra active. Dropping the
// extra here would let resolver.missingExtras attribute the request to a
// base that survives while the extra that actually asked was abandoned.
p.recordExtraRequests(pkg, v, expanded)

// Only now is the version definitely offered, so only now is an
// Offered:true record truthful.
Expand Down Expand Up @@ -329,3 +334,92 @@ func expandRequirements(reqs []requirement.Requirement, env marker.Environment,

return deps, "", nil
}

// ExtraRequest is one requester -> package[extra] edge, recorded as
// expandRequirements builds a dependency onto an extra virtual package. It
// says who asked, independent of whether the target version turns out to
// declare the extra.
//
// This is what was ASKED for, not what the resolution settled on: the solver
// can visit this edge on a branch it later backtracks past. resolver.Resolve
// keeps only the edges the final solution still contains.
type ExtraRequest struct {
// Requester is Root() or a Package identifying who asked. When the
// requester is itself an extra (e.g. base[extraA] asking for
// other[extraB]), Requester.Extra carries that -- resolver.missingExtras
// needs it to tell an abandoned base[extraA] apart from a base that
// survived with no extra active.
Requester Package

// RequesterVersion is the requester's pinned version. Meaningless when
// Requester is Root().
RequesterVersion version.Version

// Package is the project whose extra was requested.
Package index.PackageName

// Extra is the PEP 685-normalized extra requested.
Extra string
}

// ExtraRequests returns every requester -> package[extra] edge this Provider
// has seen, including ones on a branch the solver later backtracked past.
func (p *Provider) ExtraRequests() []ExtraRequest {
return p.extraRequests
}

// recordExtraRequests scans deps for edges onto an extra virtual package.
// rootDependencies and dependenciesFrom call this once they have expanded
// their own requirements, because they are the ones who know their requester
// identity -- the solver's Dependencies method is never told who is asking.
func (p *Provider) recordExtraRequests(requester Package, requesterVersion version.Version, deps []dependency) {
for _, d := range deps {
if d.Package.Kind != KindProject || d.Package.Extra == "" {
continue
}
p.recordExtraRequest(requester, requesterVersion, d.Package.Name, d.Package.Extra)
}
}

// recordExtraRequest adds one edge, ignoring a repeat of one already held. See
// Provider.record for why the dedupe key is built from strings.
func (p *Provider) recordExtraRequest(
requester Package, requesterVersion version.Version, name index.PackageName, extra string,
) {
key := strings.Join([]string{requester.String(), requesterVersion.String(), string(name), extra}, "\x00")
if p.extraRequestsSeen[key] {
return
}
p.extraRequestsSeen[key] = true
p.extraRequests = append(p.extraRequests, ExtraRequest{
Requester: requester,
RequesterVersion: requesterVersion,
Package: name,
Extra: extra,
})
}

// UndeclaredExtra records that one project's specific version does not
// declare an extra the resolution asked about.
type UndeclaredExtra struct {
Package index.PackageName
Version version.Version
Extra string
}

// UndeclaredExtras returns every (package, version, extra) this Provider found
// not declared, including versions the solver later backtracked past.
func (p *Provider) UndeclaredExtras() []UndeclaredExtra {
return p.undeclaredExtras
}

// recordUndeclaredExtra adds one record, ignoring a repeat of one already
// held.
func (p *Provider) recordUndeclaredExtra(name index.PackageName, v version.Version, extra string) {
key := strings.Join([]string{string(name), v.String(), extra}, "\x00")
if p.undeclaredExtrasSeen[key] {
return
}
p.undeclaredExtrasSeen[key] = true
p.undeclaredExtras = append(p.undeclaredExtras, UndeclaredExtra{Package: name, Version: v, Extra: extra})
}
45 changes: 21 additions & 24 deletions provider/extras_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,24 @@ func TestExtraDependsOnItsBaseAtExactlyTheSameVersion(t *testing.T) {
}
}

// PackageMetadata.ProvidesExtra exists precisely so pkg[tests] where the extra
// is spelled test does not resolve happily and install nothing. Asserted
// through Candidates, because found == false is exactly the signal the solver
// reads as "no such thing" and turns into an explanation.
func TestUnknownExtraHasNoCandidates(t *testing.T) {
// An undeclared extra is ignored, as pip and uv do, rather than excluding the
// version: pkg[tests], where the extra is spelled test, still resolves to the
// newest version. Asserted through Candidates, since found == true and best
// unchanged is the signal the solver reads as "usable".
func TestUnknownExtraHasCandidatesAndPicksTheNewestVersion(t *testing.T) {
idx := index.NewMockIndex("test").
SetMetadata("flask", "3.0.0", index.PackageMetadata{
ProvidesExtra: []string{"async"},
})

p := provider.New(context.Background(), idx, testOptions(t))

if _, found, _, err := p.Candidates(provider.WithExtra("flask", "asynk"), pep440set.All()); err != nil || found {
t.Errorf("misspelled extra: found = %v, err = %v; want false, nil", found, err)
best, found, _, err := p.Candidates(provider.WithExtra("flask", "asynk"), pep440set.All())
if err != nil || !found {
t.Fatalf("misspelled extra: found = %v, err = %v; want true, nil", found, err)
}
if got := bestVersion(t, best); got.String() != "3.0.0" {
t.Errorf("misspelled extra: best = %s, want 3.0.0", got)
}
if _, found, _, err := p.Candidates(provider.WithExtra("flask", "async"), pep440set.All()); err != nil || !found {
t.Errorf("declared extra: found = %v, err = %v; want true, nil", found, err)
Expand All @@ -101,18 +105,12 @@ func TestUnknownExtraHasNoCandidates(t *testing.T) {
}
}

// Only the versions that declare the extra are SELECTABLE for it, which is what
// makes "this package has that extra only from 3.0 on" resolvable rather than a
// silent no-op.
//
// ⚠️ Note what rank does and does not say here. Three versions are in range and
// only two provide the extra, and rank reports 3 — it counts what is in range
// before usability is tested, deliberately, because testing usability is the cost
// this provider exists to avoid. Over-counting is what this provider chooses --
// go-pubgrub requires no bound either way -- and this is that gap in action. What
// must still be exact is best (the newest version actually providing the extra)
// and found.
func TestCandidatesForAnExtraSelectOnlyVersionsThatProvideIt(t *testing.T) {
// Every version is SELECTABLE for an extra, whether or not it declares one:
// an undeclared extra is ignored rather than excluding the version. best is
// the newest version overall, not the newest that happens to provide the
// extra -- 2.0 (which does not declare "async") is exactly as usable as 3.0
// and 4.0 here.
func TestCandidatesForAnExtraAreEveryVersionRegardlessOfWhatItProvides(t *testing.T) {
idx := index.NewMockIndex("test").
SetMetadata("flask", "2.0", index.PackageMetadata{}).
SetMetadata("flask", "3.0", index.PackageMetadata{ProvidesExtra: []string{"async"}}).
Expand All @@ -125,14 +123,13 @@ func TestCandidatesForAnExtraSelectOnlyVersionsThatProvideIt(t *testing.T) {
t.Fatalf("Candidates: %v", err)
}
if !found {
t.Fatal("found = false, want true: 3.0 and 4.0 both provide the extra")
t.Fatal("found = false, want true: all three versions are usable")
}
if got := bestVersion(t, best); got.String() != "4.0" {
t.Errorf("best = %s, want 4.0 — the newest version that actually provides the extra, "+
"which is the part that must NOT be approximate", got)
t.Errorf("best = %s, want 4.0 — the newest version overall", got)
}
if rank < 2 {
t.Errorf("rank = %d, want at least 2: rank may over-count but must never under-count "+
if rank < 3 {
t.Errorf("rank = %d, want at least 3: rank may over-count but must never under-count "+
"the usable versions, or the heuristic would prefer this package over one that "+
"genuinely has fewer", rank)
}
Expand Down
28 changes: 22 additions & 6 deletions provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ type Provider struct {
unusable []Unusable
recorded map[string]bool

// extraRequests holds every requester -> package[extra] edge seen while
// expanding requirements, and extraRequestsSeen is its dedupe key set. See
// recordExtraRequest. This is what was ASKED for, independent of whether
// the target version ends up declaring the extra -- resolver.Resolve
// filters it against the final solution to build Resolution.MissingExtras.
extraRequests []ExtraRequest
extraRequestsSeen map[string]bool

// undeclaredExtras holds each (package, version, extra) this Provider found
// not declared, and undeclaredExtrasSeen is its dedupe key set. See
// recordUndeclaredExtra.
undeclaredExtras []UndeclaredExtra
undeclaredExtrasSeen map[string]bool

// ranked memoizes candidate.Rank over a package's FULL version list, so the
// sort is paid once per package per resolution rather than once per
// Candidates call. See rankedVersions.
Expand All @@ -120,12 +134,14 @@ func New(ctx context.Context, idx index.MetadataIndex, opts Options) *Provider {
opts.RootVersion = version.MustParse("0")
}
return &Provider{
ctx: ctx,
index: idx,
opts: opts,
recorded: make(map[string]bool),
ranked: make(map[index.PackageName][]version.Version),
tagFilter: tagFilteringEnabled(idx, opts.WheelTags),
ctx: ctx,
index: idx,
opts: opts,
recorded: make(map[string]bool),
extraRequestsSeen: make(map[string]bool),
undeclaredExtrasSeen: make(map[string]bool),
ranked: make(map[index.PackageName][]version.Version),
tagFilter: tagFilteringEnabled(idx, opts.WheelTags),
}
}

Expand Down
32 changes: 17 additions & 15 deletions provider/solve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,25 +239,27 @@ func TestSolveWithoutTheExtraLeavesItsRequirementOut(t *testing.T) {

// A misspelled extra must fail the resolve rather than install nothing and
// report success. This is the end-to-end form of the ProvidesExtra check.
func TestSolveMisspelledExtraFails(t *testing.T) {
// A misspelled extra is ignored, as pip and uv do, rather than failing the
// resolve: it does not backtrack away from the newest version, and it does
// not pull in the real extra's requirements. See the paired resolver test,
// TestResolveReportsAMissingExtra, for Resolution.MissingExtras.
func TestSolveMisspelledExtraIsIgnoredAndPicksTheNewestVersion(t *testing.T) {
idx := index.NewMockIndex("test").
SetMetadata("flask", "3.0", index.PackageMetadata{ProvidesExtra: []string{"async"}})

_, _, err := solve(t, idx, "flask[asynk]")
AddVersion("flask", "2.0").
SetMetadata("flask", "3.0", index.PackageMetadata{
RequiresDist: mustRequirements(t, `asgiref>=3.2; extra == "async"`),
ProvidesExtra: []string{"async"},
}).
AddVersion("asgiref", "3.7")

var unsolvable *solver.Unsolvable[provider.Package, pep440set.Set]
if !errors.As(err, &unsolvable) {
t.Fatalf("err = %v, want *solver.Unsolvable", err)
got, _, err := solve(t, idx, "flask[asynk]")
if err != nil {
t.Fatalf("Solve: %v", err)
}

// Asserting only the error TYPE would keep this test green if flask became
// unresolvable for some unrelated reason, which is precisely the failure it
// exists to distinguish. Pin the derivation to the misspelled extra.
if !causeMentions(unsolvable.RootCause, func(pkg provider.Package) bool {
return pkg == provider.WithExtra("flask", "asynk")
}) {
t.Errorf("root cause does not mention flask[asynk]; the resolve failed for some other reason: %v",
unsolvable.RootCause)
assertSelected(t, got, map[string]string{"flask": "3.0"})
if _, ok := got["asgiref"]; ok {
t.Errorf("the misspelled extra pulled in asgiref, which only the real async extra declares: %v", got)
}
}

Expand Down
29 changes: 23 additions & 6 deletions resolver/packse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,6 @@ var knownFail = map[string]string{
"PEP 592 (and uv) still allow it when a requirement pins it exactly, which this scenario's root does for b==1.0.0",
"yanked/transitive-yanked-and-unyanked-dependency-opt-in": "FilteredIndex.ExcludeYanked drops a yanked version outright; " +
"PEP 592 (and uv) still allow it when a requirement pins it exactly, which this scenario's root does for c==2.0.0",

"extras/missing-extra": "go-pyresolver models name[extra] as a virtual package requiring a candidate that " +
"declares the extra, so a version that omits it is excluded rather than the extra being silently dropped; " +
"uv ignores an extra no candidate provides",
"extras/extra-does-not-exist-backtrack": "same gap as extras/missing-extra: the newest version (3.0.0) does not " +
"provide the extra, so go-pyresolver backtracks to the one that does (1.0.0) instead of dropping the extra",
}

// divergence is one scenario's entry on intentionalDivergence: the reason and
Expand Down Expand Up @@ -304,6 +298,29 @@ func TestPackse(t *testing.T) {
return
}

if name == "extras/missing-extra" {
// The harness can assert the warning here, so it does: the root
// asked for a[extra], and 1.0.0 (the only version) does not
// declare it.
res, resolveErr := resolvePackseScenario(t, s)
matched, detail := matchOutcome(t, res, resolveErr, s.Expected.Satisfiable, s.Expected.Packages)
if !matched {
t.Errorf("%s: %s", name, detail)
}
if resolveErr == nil {
want := []resolver.MissingExtra{{
Package: index.NewPackageName("a"),
Version: version.MustParse("1.0.0"),
Extra: "extra",
RequestedBy: resolver.Requester{Root: true},
}}
if !reflect.DeepEqual(res.MissingExtras, want) {
t.Errorf("%s: MissingExtras = %+v, want %+v", name, res.MissingExtras, want)
}
}
return
}

matched, detail := runPackseScenario(t, s)
switch {
case isKnownFail && matched:
Expand Down
Loading
Loading