Skip to content

Commit d74094d

Browse files
committed
fix: static dep on root package
This led to an unhelpful InconsistentCandidate error before.
1 parent 4907a31 commit d74094d

5 files changed

Lines changed: 117 additions & 71 deletions

File tree

questionpy_server/dependencies/_solver/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
)
1414
from questionpy_server.dependencies._dynamic_resolver_abc import DynamicDependencyResolver
1515

16-
from ._model import RootPackage
16+
from ._model import RootRequirementAndCandidate
1717
from ._provider import QPyResolvelibProvider
1818
from ._reporter import QPyResolvelibReporter
1919
from .errors import DependencyConflictError, QPyDependencyError
@@ -37,7 +37,9 @@ def resolve_dependency_tree(
3737
reporter = QPyResolvelibReporter(root.nssn)
3838
resolver = resolvelib.Resolver(provider, reporter)
3939

40-
root_node = RootPackage(root.nssn, Version.parse(root.version), DistDependencies(qpy=list(root_dependencies)))
40+
root_node = RootRequirementAndCandidate(
41+
root.nssn, Version.parse(root.version), DistDependencies(qpy=list(root_dependencies))
42+
)
4143

4244
try:
4345
result = resolver.resolve((root_node,))
@@ -62,4 +64,8 @@ def resolve_dependency_tree(
6264
msg = "Unknown resolution error"
6365
raise QPyDependencyError(msg) from e
6466

65-
return {nssn: candidate for nssn, candidate in result.mapping.items() if not isinstance(candidate, RootPackage)}
67+
return {
68+
nssn: candidate
69+
for nssn, candidate in result.mapping.items()
70+
if not isinstance(candidate, RootRequirementAndCandidate)
71+
}

questionpy_server/dependencies/_solver/_model.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class DynamicRequirement:
1515
def nssn(self) -> PackageNamespaceAndShortName:
1616
return self.dep.nssn
1717

18+
def __str__(self) -> str:
19+
string = str(self.dep.version) if self.dep.version else "any version"
20+
string += " (including prereleases)" if self.dep.include_prereleases else " (excluding prereleases)"
21+
return string
22+
1823

1924
@dataclass(frozen=True)
2025
class StaticRequirement:
@@ -25,20 +30,26 @@ class StaticRequirement:
2530
def nssn(self) -> PackageNamespaceAndShortName:
2631
return self.dep.nssn
2732

33+
def __str__(self) -> str:
34+
return f"statically packaged version {self.dep.version} ({self.dep.hash})"
35+
2836

2937
@dataclass(frozen=True)
30-
class RootPackage:
38+
class RootRequirementAndCandidate:
3139
"""Represents the root package as both a requirement and a candidate."""
3240

3341
nssn: PackageNamespaceAndShortName
3442
version: Version
3543
dependencies: DistDependencies
3644

45+
def __str__(self) -> str:
46+
return f"root package ({self.nssn}:{self.version})"
47+
3748

38-
type Requirement = DynamicRequirement | StaticRequirement | RootPackage
49+
type Requirement = DynamicRequirement | StaticRequirement | RootRequirementAndCandidate
3950

4051

4152
type DynamicCandidate = DynamicDependencySolution
4253
type StaticCandidate = StaticDependencySolution
4354

44-
type Candidate = DynamicCandidate | StaticCandidate | RootPackage
55+
type Candidate = DynamicCandidate | StaticCandidate | RootRequirementAndCandidate

questionpy_server/dependencies/_solver/_provider.py

Lines changed: 90 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
from collections.abc import Iterable, Iterator, Mapping, Sequence
2-
from typing import cast
32

43
import resolvelib
54
from resolvelib.structs import Matches, RequirementInformation
65
from semver import Version
76

87
from questionpy_common import PackageNamespaceAndShortName
9-
from questionpy_common.dependencies import DependencySolution, DynamicDependencySolution, StaticDependencySolution
8+
from questionpy_common.dependencies import DynamicDependencySolution, StaticDependencySolution
109
from questionpy_common.manifest import AbstractDynamicQPyDependency, DistDynamicQPyDependency, DistStaticQPyDependency
1110
from questionpy_common.version_specifiers import QPyDependencyVersionSpecifier
1211
from questionpy_server.dependencies._dynamic_resolver_abc import (
@@ -17,7 +16,7 @@
1716
Candidate,
1817
DynamicRequirement,
1918
Requirement,
20-
RootPackage,
19+
RootRequirementAndCandidate,
2120
StaticRequirement,
2221
)
2322

@@ -46,75 +45,96 @@ def _merge_dynamic_deps(dep: AbstractDynamicQPyDependency, *deps: AbstractDynami
4645

4746
def _partition_reqs(
4847
reqs: Iterable[Requirement],
49-
) -> tuple[Sequence[DynamicRequirement], Sequence[StaticRequirement]]:
48+
) -> tuple[Sequence[DynamicRequirement], Sequence[StaticRequirement], RootRequirementAndCandidate | None]:
5049
dynamic: list[DynamicRequirement] = []
5150
static: list[StaticRequirement] = []
51+
root: RootRequirementAndCandidate | None = None
5252

5353
for req in reqs:
5454
if isinstance(req, DynamicRequirement):
5555
dynamic.append(req)
5656
elif isinstance(req, StaticRequirement):
5757
static.append(req)
58+
else:
59+
root = req
5860

59-
return dynamic, static
61+
return dynamic, static, root
6062

6163

62-
def _find_solutions(
63-
nssn: PackageNamespaceAndShortName,
64-
reqs: Iterable[DynamicRequirement | StaticRequirement],
65-
resolver: DynamicDependencyResolver,
66-
) -> Iterable[DependencySolution]:
67-
dynamic_reqs, static_reqs = _partition_reqs(reqs)
68-
69-
if static_reqs:
70-
for static_req in static_reqs[1:]:
71-
# We only compare the hash, since future changes in the manifest format might lead to inconsequential
72-
# differences between the 'dependencies' fields.
73-
if static_req.dep.hash != static_reqs[0].dep.hash:
74-
# There are multiple _different_ static versions of the dependency required.
75-
return ()
64+
def _do_dynamic_reqs_allow_candidate(dynamic_reqs: Sequence[DynamicRequirement], cand_version: str | Version) -> bool:
65+
if isinstance(cand_version, str):
66+
cand_version = Version.parse(cand_version)
7667

77-
# All the static dependencies are equivalent. We'll use the first.
78-
chosen_static_req = static_reqs[0]
79-
chosen_static_version = Version.parse(chosen_static_req.dep.version)
68+
for dynamic_req in dynamic_reqs:
69+
allows = (dynamic_req.dep.include_prereleases or cand_version.prerelease is None) and (
70+
dynamic_req.dep.version is None or dynamic_req.dep.version.allows(cand_version)
71+
)
72+
if not allows:
73+
return False
8074

81-
for dynamic_req in dynamic_reqs:
82-
if dynamic_req.dep.version and not dynamic_req.dep.version.allows(chosen_static_version):
83-
# At least one dynamic dependency does not allow the static version.
84-
return ()
75+
return True
8576

86-
return (
87-
StaticDependencySolution(
88-
nssn=nssn,
89-
owner=chosen_static_req.owner,
90-
hash=chosen_static_req.dep.hash,
91-
version=chosen_static_req.dep.version,
92-
dependencies=chosen_static_req.dep.dependencies,
93-
),
77+
78+
def _find_static_matches(
79+
nssn: PackageNamespaceAndShortName,
80+
static_reqs: Sequence[StaticRequirement],
81+
dynamic_reqs: Sequence[DynamicRequirement],
82+
) -> Iterable[StaticDependencySolution]:
83+
"""When one or more static requirements exists for a package, check that they're the same and return solutions."""
84+
for static_req in static_reqs[1:]:
85+
# We only compare the hash, since future changes in the manifest format might lead to inconsequential
86+
# differences between the 'dependencies' fields.
87+
if static_req.dep.hash != static_reqs[0].dep.hash:
88+
# There are multiple _different_ static versions of the dependency required.
89+
return ()
90+
91+
# All the static dependencies are equivalent.
92+
93+
if not _do_dynamic_reqs_allow_candidate(dynamic_reqs, static_reqs[0].dep.version):
94+
# At least one dynamic dependency does not allow the static version.
95+
return ()
96+
97+
return (
98+
StaticDependencySolution(
99+
nssn=nssn,
100+
owner=static_req.owner,
101+
hash=static_req.dep.hash,
102+
version=static_req.dep.version,
103+
dependencies=static_req.dep.dependencies,
94104
)
105+
for static_req in static_reqs
106+
)
107+
95108

96-
# Only dynamic dependencies for this NSSN have so far been discovered.
109+
def _find_dynamic_matches(
110+
nssn: PackageNamespaceAndShortName, dynamic_reqs: Sequence[DynamicRequirement], resolver: DynamicDependencyResolver
111+
) -> Iterator[DynamicDependencySolution]:
112+
"""When only dynamic requirements exist for a package, find all matching available package versions."""
97113
merged = _merge_dynamic_deps(*(req.dep for req in dynamic_reqs))
98114

99115
# TODO: Use locked version if possible.
100-
matching_package_versions = resolver.get_matching_versions(
101-
nssn=nssn,
102-
version_spec=merged.version,
103-
include_prereleases=merged.include_prereleases,
116+
# We sort from highest (i.e. latest) version to lowest (i.e. oldest), since resolvelib tries candidates in order.
117+
matching_package_versions = sorted(
118+
resolver.get_matching_versions(
119+
nssn=nssn,
120+
version_spec=merged.version,
121+
include_prereleases=merged.include_prereleases,
122+
),
123+
key=lambda apv: apv.version,
124+
reverse=True,
104125
)
105126

106127
return (
107128
DynamicDependencySolution(
108129
nssn=nssn,
109-
hash=available_package.hash,
110-
version=available_package.manifest.version,
111-
dependencies=available_package.manifest.dependencies,
130+
hash=apv.hash,
131+
version=apv.manifest.version,
132+
dependencies=apv.manifest.dependencies,
112133
)
113-
for available_package in matching_package_versions
134+
for apv in matching_package_versions
114135
)
115136

116137

117-
# Implement logic so the resolver understands the requirement format.
118138
class QPyResolvelibProvider(resolvelib.AbstractProvider[Requirement, Candidate, PackageNamespaceAndShortName]):
119139
def __init__(self, dynamic_resolver: DynamicDependencyResolver) -> None:
120140
self._dynamic_resolver = dynamic_resolver
@@ -177,28 +197,37 @@ def find_matches(
177197
requirements: Mapping[PackageNamespaceAndShortName, Iterator[Requirement]],
178198
incompatibilities: Mapping[PackageNamespaceAndShortName, Iterator[Candidate]],
179199
) -> Matches[Candidate]:
180-
reqs = list(requirements.get(identifier, ()))
181-
incompatible_candidates = list(incompatibilities.get(identifier, ()))
200+
reqs = tuple(requirements.get(identifier, ()))
201+
incompatible_candidates = tuple(incompatibilities.get(identifier, ()))
202+
203+
if not reqs:
204+
msg = f"There is no requirement on '{identifier}', why are we resolving it?"
205+
raise RuntimeError(msg)
206+
207+
dynamic_reqs, static_reqs, root_req = _partition_reqs(reqs)
182208

183-
root_req = next((req for req in reqs if isinstance(req, RootPackage)), None)
184209
if root_req:
185210
if root_req in incompatible_candidates:
211+
# The root requirement has for some reason been marked as incompatible in a previous backtracking round.
212+
return ()
213+
if static_reqs:
214+
# There is also a static dependency on the root package, which isn't allowed.
186215
return ()
216+
217+
# If there is a dynamic dependency on the root package, it's always a cycle.
218+
# We could return () in that case, but letting the cycle check later on handle this will lead to a better
219+
# error message than we could generate here.
220+
if not _do_dynamic_reqs_allow_candidate(dynamic_reqs, root_req.version):
221+
# Of course, if the version doesn't match, we still prevent it.
222+
return ()
223+
187224
return (root_req,)
188225

189-
# If we're here, then there is no root requirement. (i.e., this is not the root package.)
190-
191-
return sorted(
192-
(
193-
solution
194-
for solution in _find_solutions(
195-
identifier, cast("list[DynamicRequirement | StaticRequirement]", reqs), self._dynamic_resolver
196-
)
197-
if solution not in incompatible_candidates
198-
),
199-
key=lambda solution: solution.version,
200-
reverse=True,
201-
)
226+
if static_reqs:
227+
return _find_static_matches(identifier, static_reqs, dynamic_reqs)
228+
229+
# Only dynamic dependencies for this NSSN have so far been discovered.
230+
return _find_dynamic_matches(identifier, dynamic_reqs, self._dynamic_resolver)
202231

203232
def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> bool:
204233
if isinstance(requirement, StaticRequirement):
@@ -207,7 +236,7 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo
207236
return isinstance(candidate, StaticDependencySolution) and candidate.hash == requirement.dep.hash
208237

209238
# The root requirement is only satisfied by the root candidate.
210-
if isinstance(requirement, RootPackage):
239+
if isinstance(requirement, RootRequirementAndCandidate):
211240
return requirement == candidate
212241

213242
# Dynamic requirements can be satisfied by any kind of candidate so long as the versions match.

questionpy_server/dependencies/_solver/_reporter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ._model import (
1313
Candidate,
1414
Requirement,
15-
RootPackage,
15+
RootRequirementAndCandidate,
1616
)
1717
from .errors import DependencyCycleError, TooDeeplyNestedDependencyError
1818

@@ -29,7 +29,7 @@ def __init__(self, root_nssn: PackageNamespaceAndShortName) -> None:
2929
self._messages: list[str] = []
3030

3131
def pinning(self, candidate: Candidate) -> None:
32-
if isinstance(candidate, RootPackage):
32+
if isinstance(candidate, RootRequirementAndCandidate):
3333
# Not very interesting...
3434
return
3535

questionpy_server/dependencies/_solver/errors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from questionpy_common.constants import MAX_QPY_DEPENDENCY_LEVELS
77
from questionpy_common.manifest import AbstractDynamicQPyDependency, DistStaticQPyDependency
88

9-
from ._model import Candidate, Requirement, RootPackage
9+
from ._model import Candidate, Requirement, RootRequirementAndCandidate
1010

1111

1212
def _dep_version_to_str(dep: AbstractDynamicQPyDependency | DistStaticQPyDependency) -> str:
@@ -32,7 +32,7 @@ def __init__(
3232
) -> None:
3333
msg = f"No version of '{nssn}' could be found that satisfies all of the following dependencies:"
3434
for req, parent in causes:
35-
if isinstance(req, RootPackage):
35+
if isinstance(req, RootRequirementAndCandidate):
3636
msg += f"\n\t- root package ({req.version})"
3737
else:
3838
parent_str = f"'{parent.nssn}:{parent.version}'" if parent else "unexpected top-level requirement"

0 commit comments

Comments
 (0)