nab-resolver 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """PubGrub dependency resolver."""
@@ -0,0 +1,491 @@
1
+ """Conflict resolution and backtracking for the PubGrub resolver.
2
+
3
+ Owns the conflict-resolution loop, the most-recent-satisfier
4
+ search, the always-learn force-resolution gate, the targeted
5
+ backtrack queue, and the catastrophic restart handler.
6
+
7
+ Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#conflict-resolution
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .errors import ResolutionError
15
+ from .incompat_index import add_incompatibility
16
+ from .partial_solution import PartialSolution
17
+ from .report import format_error, prior_cause
18
+ from .root import ROOT
19
+ from .types import Incompatibility, IncompatibilityCause
20
+
21
+ if TYPE_CHECKING:
22
+ from .partial_solution import Assignment
23
+ from .resolver import Resolver
24
+ from .types import Term
25
+
26
+
27
+ __all__ = [
28
+ "apply_targeted_backtrack",
29
+ "conflict_resolution",
30
+ "find_most_recent_satisfier",
31
+ "force_targeted_backtrack",
32
+ "is_terminal_incompatibility",
33
+ "iterate_force_resolution",
34
+ "maybe_restart",
35
+ "maybe_targeted_backtrack",
36
+ "recompute_previous_level",
37
+ "try_force_resolution_step",
38
+ "update_culprit_counts",
39
+ ]
40
+
41
+
42
+ # Soundness gate for try_force_resolution_step: a single-term
43
+ # incompatibility must resolve to >= 2 terms to keep the eliminated
44
+ # package's conditioning.
45
+ _SINGLE_TERM = 1
46
+ _MIN_RESOLVED_TERMS = 2
47
+
48
+ # Lowest decision level a targeted-backtrack can land above without
49
+ # removing ROOT (decided at level 1 in _add_root_requirements).
50
+ _TARGETED_BT_MIN_LEVEL = 2
51
+
52
+
53
+ def conflict_resolution(
54
+ resolver: Resolver[Any, Any],
55
+ conflicting_incompatibility: Incompatibility[Any, Any],
56
+ ) -> Incompatibility[Any, Any]:
57
+ """Learn a new incompatibility and backjump to the appropriate level.
58
+
59
+ Implements PubGrub's conflict resolution algorithm: resolve
60
+ backwards through the assignment trail combining incompatibilities
61
+ until the learned clause has at most one term at the current
62
+ decision level.
63
+
64
+ Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#conflict-resolution
65
+ """
66
+ current_incompatibility = conflicting_incompatibility
67
+ is_derived = False
68
+
69
+ while True:
70
+ if is_terminal_incompatibility(current_incompatibility):
71
+ # Unreachable in practice: the backjump_target == 0 check below
72
+ # preempts this path. Kept as a safety net.
73
+ raise ResolutionError( # pragma: no cover
74
+ format_error(current_incompatibility),
75
+ incompatibility=current_incompatibility,
76
+ )
77
+
78
+ satisfier_result = find_most_recent_satisfier(resolver, current_incompatibility)
79
+ most_recent_satisfier = satisfier_result[0]
80
+ most_recent_satisfier_term = satisfier_result[1]
81
+ previous_satisfier_level = satisfier_result[2]
82
+
83
+ can_backjump = (
84
+ most_recent_satisfier.is_decision
85
+ or previous_satisfier_level != most_recent_satisfier.decision_level
86
+ )
87
+
88
+ if can_backjump:
89
+ (
90
+ current_incompatibility,
91
+ most_recent_satisfier,
92
+ most_recent_satisfier_term,
93
+ previous_satisfier_level,
94
+ forced_any,
95
+ ) = iterate_force_resolution(
96
+ resolver,
97
+ current_incompatibility,
98
+ most_recent_satisfier,
99
+ most_recent_satisfier_term,
100
+ previous_satisfier_level,
101
+ )
102
+ if forced_any:
103
+ is_derived = True
104
+
105
+ resolver.observer.on_conflict_step(
106
+ current_incompatibility,
107
+ satisfier_package=most_recent_satisfier.package,
108
+ satisfier_is_decision=most_recent_satisfier.is_decision,
109
+ satisfier_level=most_recent_satisfier.decision_level,
110
+ previous_level=previous_satisfier_level,
111
+ can_backjump=can_backjump,
112
+ )
113
+
114
+ if can_backjump:
115
+ if is_derived:
116
+ add_incompatibility(resolver, current_incompatibility)
117
+ resolver.stats.incompatibilities_learned += 1
118
+ resolver.observer.on_learned(current_incompatibility)
119
+
120
+ backjump_target = previous_satisfier_level
121
+ if (
122
+ most_recent_satisfier.is_decision
123
+ and backjump_target >= most_recent_satisfier.decision_level
124
+ ):
125
+ backjump_target = most_recent_satisfier.decision_level - 1
126
+
127
+ backjump_target = min(backjump_target, resolver.solution.decision_level)
128
+ backjump_target = max(backjump_target, 0)
129
+
130
+ if backjump_target == 0:
131
+ raise ResolutionError(
132
+ format_error(current_incompatibility),
133
+ incompatibility=current_incompatibility,
134
+ )
135
+
136
+ from_level = resolver.solution.decision_level
137
+ resolver.solution.backtrack(backjump_target)
138
+ resolver.stats.backjumps += 1
139
+ resolver.observer.on_backjump(from_level, backjump_target)
140
+
141
+ # Count only the "affected" package so it gets decided first
142
+ # after restart and its dependencies constrain the culprit.
143
+ affected_package = most_recent_satisfier.package
144
+ resolver.stats.package_conflict_counts[affected_package] += 1
145
+
146
+ update_culprit_counts(
147
+ resolver,
148
+ current_incompatibility,
149
+ affected_package,
150
+ most_recent_satisfier,
151
+ )
152
+
153
+ return current_incompatibility
154
+
155
+ # Can't backjump yet: resolve with the satisfier's cause.
156
+ assert most_recent_satisfier.cause is not None
157
+ assert most_recent_satisfier_term is not None
158
+ is_derived = True
159
+
160
+ resolved_terms = prior_cause(
161
+ current_incompatibility,
162
+ most_recent_satisfier.cause,
163
+ most_recent_satisfier_term.package,
164
+ )
165
+
166
+ current_incompatibility = Incompatibility(
167
+ resolved_terms,
168
+ cause=IncompatibilityCause.DERIVED,
169
+ cause_left=current_incompatibility,
170
+ cause_right=most_recent_satisfier.cause,
171
+ )
172
+
173
+
174
+ def update_culprit_counts(
175
+ resolver: Resolver[Any, Any],
176
+ incompatibility: Incompatibility[Any, Any],
177
+ affected_package: Any,
178
+ satisfier: Assignment[Any, Any],
179
+ ) -> None:
180
+ """Credit non-affected packages in a learned clause as culprits.
181
+
182
+ Modelled on uv's ConflictTracker (PR #9843). Every non-affected,
183
+ non-root package in the clause is a culprit; for single-term NO_VERSIONS
184
+ clauses we walk the satisfier's cause chain instead. When a culprit
185
+ crosses ``CULPRIT_THRESHOLD`` it gets queued for targeted backtrack.
186
+ """
187
+ culprit_packages: set[Any] = set()
188
+ for term in incompatibility.terms:
189
+ package = term.package
190
+ if package is ROOT or package == affected_package:
191
+ continue
192
+ culprit_packages.add(package)
193
+
194
+ # Single-term NO_VERSIONS clauses carry the antecedent decisions only
195
+ # via the satisfier's cause; without this, those decisions go uncredited.
196
+ if len(incompatibility.terms) == 1 and satisfier.cause is not None:
197
+ for term in satisfier.cause.terms:
198
+ package = term.package
199
+ if package is ROOT or package == affected_package:
200
+ continue
201
+ culprit_packages.add(package)
202
+
203
+ threshold = resolver.CULPRIT_THRESHOLD
204
+ for package in culprit_packages:
205
+ resolver.stats.package_culprit_counts[package] += 1
206
+ count = resolver.stats.package_culprit_counts[package]
207
+ if (
208
+ count >= threshold
209
+ and count % threshold == 0
210
+ and package not in resolver.pending_targeted_backtrack
211
+ ):
212
+ resolver.pending_targeted_backtrack.append(package)
213
+
214
+
215
+ def iterate_force_resolution(
216
+ resolver: Resolver[Any, Any],
217
+ incompatibility: Incompatibility[Any, Any],
218
+ satisfier: Assignment[Any, Any],
219
+ satisfier_term: Term[Any, Any],
220
+ previous_satisfier_level: int,
221
+ ) -> tuple[
222
+ Incompatibility[Any, Any],
223
+ Assignment[Any, Any],
224
+ Term[Any, Any],
225
+ int,
226
+ bool,
227
+ ]:
228
+ """Iterate :func:`try_force_resolution_step` while it succeeds.
229
+
230
+ Returns the (possibly-resolved) incompatibility, refreshed satisfier,
231
+ satisfier term, previous-satisfier level, and a ``forced_any`` flag
232
+ indicating whether at least one resolution step happened. Stops when
233
+ ``try_force_resolution_step`` declines (returns ``None``).
234
+ """
235
+ forced_any = False
236
+
237
+ while True:
238
+ forced = try_force_resolution_step(
239
+ resolver, incompatibility, satisfier, satisfier_term
240
+ )
241
+ if forced is None:
242
+ break
243
+ forced_any = True
244
+ incompatibility = forced
245
+ (
246
+ satisfier,
247
+ satisfier_term,
248
+ previous_satisfier_level,
249
+ ) = find_most_recent_satisfier(resolver, forced)
250
+
251
+ return (
252
+ incompatibility,
253
+ satisfier,
254
+ satisfier_term,
255
+ previous_satisfier_level,
256
+ forced_any,
257
+ )
258
+
259
+
260
+ def try_force_resolution_step(
261
+ resolver: Resolver[Any, Any],
262
+ incompatibility: Incompatibility[Any, Any],
263
+ satisfier: Assignment[Any, Any],
264
+ satisfier_term: Term[Any, Any],
265
+ ) -> Incompatibility[Any, Any] | None:
266
+ """Resolve a single-term NO_VERSIONS clause once with a soundness gate.
267
+
268
+ Standard PubGrub backjumps to root for single-term clauses, losing the
269
+ supporting decisions; resolving once with the satisfier's cause exposes
270
+ them so later propagation can skip the bad decision.
271
+
272
+ Returns ``None`` when the resolved clause collapses to <2 terms (the
273
+ eliminated package's conditioning would be lost) or when it is not
274
+ assert-eligible.
275
+ """
276
+ if (
277
+ satisfier.is_decision
278
+ or len(incompatibility.terms) != _SINGLE_TERM
279
+ or satisfier.cause is None
280
+ ):
281
+ return None
282
+
283
+ resolved_terms = prior_cause(
284
+ incompatibility, satisfier.cause, satisfier_term.package
285
+ )
286
+ if len(resolved_terms) < _MIN_RESOLVED_TERMS:
287
+ return None
288
+
289
+ forced = Incompatibility(
290
+ resolved_terms,
291
+ cause=IncompatibilityCause.DERIVED,
292
+ cause_left=incompatibility,
293
+ cause_right=satisfier.cause,
294
+ )
295
+
296
+ forced_recent, _, forced_prev_level = find_most_recent_satisfier(resolver, forced)
297
+ forced_can_backjump = (
298
+ forced_recent.is_decision or forced_prev_level != forced_recent.decision_level
299
+ )
300
+ if not forced_can_backjump:
301
+ return None
302
+
303
+ return forced
304
+
305
+
306
+ def find_most_recent_satisfier(
307
+ resolver: Resolver[Any, Any], incompatibility: Incompatibility[Any, Any]
308
+ ) -> tuple[Assignment[Any, Any], Term[Any, Any], int]:
309
+ """Find the most recently assigned satisfier across all terms.
310
+
311
+ Returns ``(satisfier, term, previous_satisfier_level)``. The previous
312
+ level is the highest among the other terms' satisfiers and bounds how
313
+ far back the resolver can jump. Refined when the satisfier is partial
314
+ (earlier assignments also contributed).
315
+ """
316
+ most_recent: Assignment[Any, Any] | None = None
317
+ most_recent_term: Term[Any, Any] | None = None
318
+ previous_level = 1 # root's level
319
+
320
+ for term in incompatibility.terms:
321
+ satisfier = resolver.solution.satisfier(term)
322
+ if satisfier is None: # pragma: no cover
323
+ unreachable = (
324
+ f"Bug: no satisfier for {term!r} in a satisfied incompatibility"
325
+ )
326
+ raise RuntimeError(unreachable)
327
+ satisfier_index = satisfier.trail_index
328
+ if most_recent is None or (satisfier_index > most_recent.trail_index):
329
+ if most_recent is not None:
330
+ previous_level = max(previous_level, most_recent.decision_level)
331
+ most_recent = satisfier
332
+ most_recent_term = term
333
+ else:
334
+ previous_level = max(previous_level, satisfier.decision_level)
335
+
336
+ if most_recent is None: # pragma: no cover
337
+ unreachable = "Bug: no satisfiers in a satisfied incompatibility"
338
+ raise RuntimeError(unreachable)
339
+ assert most_recent_term is not None
340
+
341
+ if not resolver.solution.satisfier_is_sole(most_recent, most_recent_term):
342
+ previous_level = recompute_previous_level(
343
+ resolver, most_recent, most_recent_term, previous_level
344
+ )
345
+
346
+ return most_recent, most_recent_term, previous_level
347
+
348
+
349
+ def recompute_previous_level(
350
+ resolver: Resolver[Any, Any],
351
+ satisfier: Assignment[Any, Any],
352
+ satisfier_term: Term[Any, Any],
353
+ current_previous_level: int,
354
+ ) -> int:
355
+ """Refine previous_level when the satisfier is partial.
356
+
357
+ Isolates the satisfier's own contribution (from its cause), subtracts
358
+ it from the term, and folds the remainder's satisfier level in.
359
+ """
360
+ if satisfier.cause is None:
361
+ return current_previous_level
362
+
363
+ cause_term = None
364
+ for term in satisfier.cause.terms:
365
+ if term.package == satisfier_term.package:
366
+ cause_term = term
367
+ break
368
+ if cause_term is None:
369
+ return current_previous_level
370
+
371
+ individual = cause_term.negate()
372
+ remainder = satisfier_term.intersect(individual.negate())
373
+ if remainder is None or remainder.constraint.is_empty:
374
+ return current_previous_level
375
+
376
+ remainder_satisfier = resolver.solution.satisfier(remainder)
377
+ if remainder_satisfier is not None:
378
+ return max(current_previous_level, remainder_satisfier.decision_level)
379
+ return current_previous_level
380
+
381
+
382
+ def is_terminal_incompatibility(incompatibility: Incompatibility[Any, Any]) -> bool:
383
+ """Check whether this incompatibility proves resolution is impossible."""
384
+ if not incompatibility.terms:
385
+ return True
386
+ return all(term.package is ROOT for term in incompatibility.terms)
387
+
388
+
389
+ def maybe_targeted_backtrack(resolver: Resolver[Any, Any]) -> Any | None:
390
+ """Run :func:`apply_targeted_backtrack` if the gate is open.
391
+
392
+ Gate: pending non-empty AND total conflicts past
393
+ ``TARGETED_BT_MIN_CONFLICTS``. Pending culprits are kept across rounds.
394
+ """
395
+ if (
396
+ resolver.pending_targeted_backtrack
397
+ and resolver.stats.conflicts >= resolver.TARGETED_BT_MIN_CONFLICTS
398
+ ):
399
+ return apply_targeted_backtrack(resolver)
400
+ return None
401
+
402
+
403
+ def maybe_restart(
404
+ resolver: Resolver[Any, Any],
405
+ restart_threshold: int,
406
+ restarts_remaining: int,
407
+ ) -> tuple[int, int, bool]:
408
+ """Restart the solver if any package crossed ``restart_threshold``.
409
+
410
+ Returns ``(new_threshold, new_remaining, restarted)``. Preserves
411
+ ``incompatibilities`` and ``package_conflict_counts`` across restart.
412
+ """
413
+ if restarts_remaining <= 0:
414
+ return restart_threshold, restarts_remaining, False
415
+
416
+ max_count = max(resolver.stats.package_conflict_counts.values(), default=0)
417
+ if max_count < restart_threshold:
418
+ return restart_threshold, restarts_remaining, False
419
+
420
+ resolver.stats.restarts += 1
421
+ resolver.solution = PartialSolution(range_type=resolver.range_type)
422
+ resolver.solution.decide(ROOT, resolver.root_version)
423
+ resolver.pending_targeted_backtrack.clear()
424
+ resolver.stats.targeted_backtracks = 0
425
+
426
+ return restart_threshold * 2, restarts_remaining - 1, True
427
+
428
+
429
+ def force_targeted_backtrack(
430
+ resolver: Resolver[Any, Any], packages: list[Any]
431
+ ) -> Any | None:
432
+ """Apply a targeted back-track without waiting for the normal gate.
433
+
434
+ Used when the provider supplies direct evidence that the named
435
+ packages are culprits. Bumps each package's culprit count past the
436
+ dominant-culprit threshold, queues it, and applies immediately.
437
+
438
+ Returns the back-jumped package, or ``None`` if the back-track did
439
+ not move the decision level.
440
+ """
441
+ if not packages:
442
+ return None
443
+
444
+ threshold = resolver.CULPRIT_THRESHOLD
445
+ for package in packages:
446
+ current = resolver.stats.package_culprit_counts[package]
447
+ if current < threshold:
448
+ resolver.stats.package_culprit_counts[package] = threshold
449
+ if package not in resolver.pending_targeted_backtrack:
450
+ resolver.pending_targeted_backtrack.append(package)
451
+
452
+ return apply_targeted_backtrack(resolver)
453
+
454
+
455
+ def apply_targeted_backtrack(resolver: Resolver[Any, Any]) -> Any | None:
456
+ """Backtrack to before the earliest pending-culprit assignment.
457
+
458
+ Picks the smallest decision-level among queued culprits' first assignments
459
+ (decision OR derivation; derivations count too so propagated culprits in
460
+ cluster conflicts still trigger a backtrack) and jumps to one before it.
461
+ Capped at ``MAX_TARGETED_BACKTRACKS`` per restart segment.
462
+
463
+ Level-1 assignments are skipped to preserve ROOT.
464
+ """
465
+ if resolver.stats.targeted_backtracks >= resolver.MAX_TARGETED_BACKTRACKS:
466
+ resolver.pending_targeted_backtrack.clear()
467
+ return None
468
+
469
+ target_level = resolver.solution.decision_level
470
+ triggering_package: Any | None = None
471
+
472
+ for package in resolver.pending_targeted_backtrack:
473
+ package_entries = resolver.solution.assignments_for(package)
474
+ # Use the package's earliest non-level-1 assignment: jumping
475
+ # further than that would just undo unrelated work.
476
+ for assignment in package_entries:
477
+ if assignment.decision_level < _TARGETED_BT_MIN_LEVEL:
478
+ continue
479
+ candidate = assignment.decision_level - 1
480
+ if candidate < target_level:
481
+ target_level = candidate
482
+ triggering_package = package
483
+ break
484
+
485
+ resolver.pending_targeted_backtrack.clear()
486
+ if triggering_package is None or target_level >= resolver.solution.decision_level:
487
+ return None
488
+
489
+ resolver.solution.backtrack(target_level)
490
+ resolver.stats.targeted_backtracks += 1
491
+ return triggering_package
nab_resolver/decide.py ADDED
@@ -0,0 +1,138 @@
1
+ """Decision making for the PubGrub resolver.
2
+
3
+ Picks the next undecided package, asks the provider for a version,
4
+ and records ``NO_VERSIONS`` clauses or constraint clauses as needed.
5
+
6
+ Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#decision-making
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from .incompat_index import add_incompatibility
14
+ from .root import ROOT
15
+ from .types import Incompatibility, IncompatibilityCause, Term
16
+
17
+ if TYPE_CHECKING:
18
+ from .resolver import Resolver
19
+
20
+ __all__ = [
21
+ "absorb_pending_clauses",
22
+ "choose_package_to_decide",
23
+ "choose_version",
24
+ "inject_constraint",
25
+ "record_no_versions",
26
+ ]
27
+
28
+
29
+ def choose_package_to_decide(resolver: Resolver[Any, Any]) -> Any | None:
30
+ """Choose the next undecided package, or None if all decided.
31
+
32
+ Prefers ``is_ready`` packages so resolution keeps making progress while
33
+ other listings/metadata are still in flight.
34
+ """
35
+ undecided = resolver.solution.undecided_packages()
36
+ undecided.discard(ROOT)
37
+ if not undecided:
38
+ return None
39
+
40
+ conflict_counts = resolver.stats.package_conflict_counts
41
+ culprit_counts = resolver.stats.package_culprit_counts
42
+ get_range = resolver.solution.get
43
+ any_range = resolver.range_type.full()
44
+ prioritize = resolver.provider.prioritize
45
+ is_ready = resolver.provider.is_ready
46
+ tiebreak_cache = resolver.tiebreak_cache
47
+ root_order = resolver.root_package_order
48
+
49
+ def sort_key(package: Any) -> tuple[Any, ...]:
50
+ priority = prioritize(
51
+ package,
52
+ get_range(package) or any_range,
53
+ conflict_counts,
54
+ culprit_counts,
55
+ )
56
+ ready_penalty = 0 if is_ready(package) else 1
57
+ tiebreak = tiebreak_cache.get(package)
58
+ if tiebreak is None:
59
+ tiebreak = root_order.get(package)
60
+ if tiebreak is None:
61
+ tiebreak = (1, 0, str(package))
62
+ tiebreak_cache[package] = tiebreak
63
+ return (ready_penalty, priority, tiebreak)
64
+
65
+ return min(undecided, key=sort_key)
66
+
67
+
68
+ def inject_constraint(resolver: Resolver[Any, Any], package: Any) -> bool:
69
+ """Inject a CONSTRAINT incompatibility for a package, if applicable.
70
+
71
+ Lazy: only fires the first time the package is about to be decided.
72
+ Returns True if an incompatibility was injected (caller re-propagates).
73
+ """
74
+ if package not in resolver.constraints:
75
+ return False
76
+ if package in resolver.injected_constraints:
77
+ return False
78
+
79
+ resolver.injected_constraints.add(package)
80
+ complement = ~resolver.constraints[package]
81
+ if complement.is_empty:
82
+ return False
83
+
84
+ add_incompatibility(
85
+ resolver,
86
+ Incompatibility(
87
+ [Term(package, complement, positive=True)],
88
+ cause=IncompatibilityCause.CONSTRAINT,
89
+ ),
90
+ )
91
+ return True
92
+
93
+
94
+ def choose_version(resolver: Resolver[Any, Any], package: Any) -> Any | None:
95
+ """Ask the provider to pick a version within the allowed range."""
96
+ current_range = resolver.solution.get(package) or resolver.range_type.full()
97
+ resolver.provider.receive_partial_solution_hint(
98
+ resolver.solution.positive_ranges(),
99
+ resolver.solution.decisions(),
100
+ )
101
+ return resolver.provider.choose_version(package, current_range)
102
+
103
+
104
+ def absorb_pending_clauses(resolver: Resolver[Any, Any]) -> bool:
105
+ """Drain provider-queued incompatibilities into the formula.
106
+
107
+ Look-ahead providers push binary clauses like
108
+ ``{candidate==v, blocking_decision==w}`` instead of relying on the
109
+ broader ``NO_VERSIONS`` clause. Returns True so the caller can suppress
110
+ the default ``NO_VERSIONS`` clause this turn.
111
+ """
112
+ clauses = list(resolver.provider.consume_pending_clauses())
113
+ for incompatibility in clauses:
114
+ add_incompatibility(resolver, incompatibility)
115
+ return bool(clauses)
116
+
117
+
118
+ def record_no_versions(
119
+ resolver: Resolver[Any, Any], package: Any, *, had_pending: bool
120
+ ) -> None:
121
+ """Add the default ``NO_VERSIONS`` clause for ``package``.
122
+
123
+ Skipped when the provider already supplied context-aware clauses;
124
+ otherwise the broad clause would persist past the backjump that lifts
125
+ the supporting decisions.
126
+ """
127
+ if had_pending:
128
+ return
129
+
130
+ current_range = resolver.solution.get(package) or resolver.range_type.full()
131
+ resolver.observer.on_no_versions(package, current_range)
132
+ add_incompatibility(
133
+ resolver,
134
+ Incompatibility(
135
+ [Term(package, current_range, positive=True)],
136
+ cause=IncompatibilityCause.NO_VERSIONS,
137
+ ),
138
+ )
nab_resolver/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Resolution error types.
2
+
3
+ Defines the exception raised when the resolver proves that no valid
4
+ solution exists, along with the derivation tree for error reporting.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from .types import Incompatibility
13
+
14
+ __all__ = [
15
+ "ResolutionError",
16
+ ]
17
+
18
+
19
+ class ResolutionError(Exception):
20
+ """Resolution failed: no valid solution exists.
21
+
22
+ The ``incompatibility`` attribute holds the root incompatibility
23
+ whose derivation tree explains why resolution failed. Walk
24
+ ``cause_left`` and ``cause_right`` to trace the full proof.
25
+
26
+ Reference: https://github.com/dart-lang/pub/blob/master/doc/solver.md#error-reporting
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ incompatibility: Incompatibility[Any, Any] | None = None,
33
+ ) -> None:
34
+ """Create a resolution error with an optional incompatibility proof."""
35
+ super().__init__(message)
36
+ self.incompatibility = incompatibility