11from collections .abc import Iterable , Iterator , Mapping , Sequence
2- from typing import cast
32
43import resolvelib
54from resolvelib .structs import Matches , RequirementInformation
65from semver import Version
76
87from questionpy_common import PackageNamespaceAndShortName
9- from questionpy_common .dependencies import DependencySolution , DynamicDependencySolution , StaticDependencySolution
8+ from questionpy_common .dependencies import DynamicDependencySolution , StaticDependencySolution
109from questionpy_common .manifest import AbstractDynamicQPyDependency , DistDynamicQPyDependency , DistStaticQPyDependency
1110from questionpy_common .version_specifiers import QPyDependencyVersionSpecifier
1211from questionpy_server .dependencies ._dynamic_resolver_abc import (
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
4746def _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.
118138class 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.
0 commit comments