From b770d0d41f113e489ca384dc2321becf6b27399c Mon Sep 17 00:00:00 2001 From: Philippe Llerena Date: Wed, 20 May 2026 14:54:15 +0200 Subject: [PATCH] feat(python,resolver): package-orderer plugin SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rer` hardcoded rez's default `SortedOrder(descending=True)` — the solver always preferred the highest version of a family, by rez's native alphanumeric-token comparison. A studio running a custom rez orderer (e.g. Fortiche's PEP 440 `Pep440Orderer`) therefore picked different versions than `rer` — the root of the version-selection divergence reported in #96. This adds a plugin SDK so a host can override the version preference, mirroring rez's own orderer model (an SDK base class + an explicit registry — not rez's heavyweight plugin-manager discovery, which orderers don't use either). ## Python SDK `pyrer` is now a mixed Rust+Python package: the compiled extension moved to the `pyrer._native` submodule; a pure-Python `pyrer` package wraps it and hosts the SDK. The restructure is transparent — `import pyrer` and every existing symbol are unchanged for callers; `pyrer.__version__` is now available. - `pyrer.PackageOrderer` — subclass it, set `name`, implement `order(family, versions) -> list[str]` (versions reordered most-preferred-first). - `pyrer.register_orderer(MyOrderer)` — register a subclass or instance under its `name`. - `pyrer.solve(..., package_orderer="")` — select by registered name, or pass a `PackageOrderer` instance, or `None` for the default. The orderer is a *preference* function: it changes which solution is found, never whether a solve succeeds. A misbehaving orderer is handled defensively — a version omitted from the result sinks to the bottom, a version not in the input is ignored. A raising `order()` surfaces as `status="error"`; no exception escapes `pyrer.solve`. ## Rust core The single solver-visible version-order site, `PackageVariantSlice:: sort_versions()`, now keys on a per-version `order_rank` (0 = most preferred) instead of `version.cmp` descending. `order_rank` is computed once per family in `PackageVariantList::new`: - No orderer → rank = version-descending position (bit-identical to the previous hardcoded behaviour). - Orderer set → the `FamilyOrderer` callback is invoked once with the family's version strings; `build_rank_map` turns its output into ranks, defensively (omitted versions sink, unknowns ignored, never panics). New `FamilyOrderer` callback type and `SolverContext.package_order` field (with a `with_package_order` builder and a manual `Debug` impl — `Rc` isn't `Debug`). `Solver::new_with_options` gains a 5th `package_order` parameter; `new` / `new_with_cache` pass `None`. The orderer is re-invoked on every `PackageVariantList` (re)build, including the issue #92 widened-range reload path — ranks are always recomputed wholesale, no stale ranks survive. ## Tests - Rust: 5 `build_rank_map` tests (permutation, omitted-sink, unknown-ignored, duplicate-first, empty-output) + 4 solver tests (reverse-preference picks lowest, pin-a-version, partial-output no-panic, no-orderer-control picks highest). 55/55 resolver unit tests pass. - Python: 10 new tests — register+select-by-name, select-by-instance, unknown name → ValueError, no-`name` → ValueError, non-orderer → TypeError, `order()` raises → status="error", partial output, `None` is default. 121/121 `pytest tests/` pass. - Strict 188-case rez differential: 188/188 with no orderer set — default ordering unchanged. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 + CHANGELOG.md | 29 +++ crates/rer-python/Cargo.toml | 8 +- crates/rer-python/pyproject.toml | 6 + crates/rer-python/python/pyrer/__init__.py | 105 ++++++++++ crates/rer-python/python/pyrer/orderer.py | 92 +++++++++ crates/rer-python/src/lib.rs | 76 ++++++- crates/rer-resolver/src/rez_solver/context.rs | 73 ++++++- crates/rer-resolver/src/rez_solver/mod.rs | 4 +- crates/rer-resolver/src/rez_solver/solver.rs | 188 +++++++++++++++--- crates/rer-resolver/src/rez_solver/variant.rs | 137 ++++++++++++- .../docs/getting-started/rez-integration.md | 57 ++++++ tests/test_rich_api.py | 122 ++++++++++++ 13 files changed, 842 insertions(+), 59 deletions(-) create mode 100644 crates/rer-python/python/pyrer/__init__.py create mode 100644 crates/rer-python/python/pyrer/orderer.py diff --git a/.gitignore b/.gitignore index 37230dc..9c74ccf 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ __pycache__ /data_set/benchmark_packages.json /data_set/benchmark_requests.json /data_set/benchmark_expected.json + +# maturin develop drops the compiled extension into the python source tree +crates/rer-python/python/pyrer/_native*.so +crates/rer-python/python/pyrer/_native*.pyd diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c6bef..c10102c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,35 @@ page. ## [Unreleased] +### Added + +- **Package-orderer plugin SDK.** `pyrer` now exposes + `pyrer.PackageOrderer` (an SDK base class) and + `pyrer.register_orderer()` (an explicit registry), mirroring rez's + own orderer model. A studio subclasses `PackageOrderer`, implements + `order(family, versions) -> list[str]` (versions reordered + most-preferred-first), registers it, and selects it via + `pyrer.solve(..., package_orderer="")` — by registered name or + by passing an instance. This lets a host override `rer`'s default + highest-version preference to match a custom rez orderer (e.g. a + PEP 440 orderer), the root cause of the version-selection divergence + in #96. The orderer is a preference function — it never changes + whether a solve succeeds; a misbehaving orderer (omitted/extra + versions) is handled defensively; a raising `order()` surfaces as + `status="error"`. On the Rust side: new `FamilyOrderer` callback + type, `SolverContext::with_package_order` builder, + `SolverContext.package_order` field, and a 5th `package_order` + parameter on `Solver::new_with_options`. + +### Changed + +- **`pyrer` is now a mixed Rust+Python package.** The compiled PyO3 + extension moved to the `pyrer._native` submodule; a pure-Python + `pyrer` package wraps it and hosts the plugin SDK. `import pyrer` + and every existing symbol (`solve`, `PackageData`, `SolveResult`, + `parse_static_package_py`, …) are unchanged for callers — the + restructure is transparent. `pyrer.__version__` is now available. + ## [1.0.0-rc.3] — 2026-05-19 First release candidate of the 1.0 line. Closes the integration diff --git a/crates/rer-python/Cargo.toml b/crates/rer-python/Cargo.toml index d5880f2..787a183 100644 --- a/crates/rer-python/Cargo.toml +++ b/crates/rer-python/Cargo.toml @@ -15,9 +15,11 @@ license = "MIT" publish = false [lib] -# The Python import name — `rer-python` ships to PyPI as `pyrer`, so -# `import pyrer` in Python loads this cdylib. -name = "pyrer" +# `pyrer` is a mixed Rust+Python package: this cdylib is the compiled +# extension `pyrer._native`, and the pure-Python `python/pyrer/` package +# wraps it and hosts the plugin SDK. `import pyrer` loads the Python +# package; it re-exports from `pyrer._native`. +name = "_native" crate-type = ["cdylib", "lib"] # abi3-py39 builds a single stable-ABI wheel that works on Python 3.9+, diff --git a/crates/rer-python/pyproject.toml b/crates/rer-python/pyproject.toml index f133e04..de4a24c 100644 --- a/crates/rer-python/pyproject.toml +++ b/crates/rer-python/pyproject.toml @@ -28,3 +28,9 @@ Repository = "https://github.com/doubleailes/rer" [tool.maturin] features = ["pyo3/extension-module"] +# Mixed Rust+Python layout: the compiled extension is built as the +# `pyrer._native` submodule, and the pure-Python package under +# `python/pyrer/` (which re-exports `_native` and hosts the plugin SDK) +# is the `pyrer` users import. +python-source = "python" +module-name = "pyrer._native" diff --git a/crates/rer-python/python/pyrer/__init__.py b/crates/rer-python/python/pyrer/__init__.py new file mode 100644 index 0000000..7e01f6b --- /dev/null +++ b/crates/rer-python/python/pyrer/__init__.py @@ -0,0 +1,105 @@ +"""pyrer — rer ("Rez En Rust"), a rez-compatible package resolver. + +``pyrer`` is a mixed Rust+Python package: the compiled solver core lives in +the :mod:`pyrer._native` extension; this module re-exports it and adds the +package-orderer plugin SDK (:class:`~pyrer.orderer.PackageOrderer`, +:func:`~pyrer.orderer.register_orderer`). + +The public surface is everything in ``__all__``. Do not import +``pyrer._native`` directly — it is an implementation detail. +""" +from __future__ import annotations + +from pyrer._native import ( + PackageData, + ResolvedVariant, + SolveResult, + parse_static_package_py, + parse_static_packages_py, +) +from pyrer._native import solve as _native_solve +from pyrer.orderer import PackageOrderer, _orderers, register_orderer + +__all__ = [ + "solve", + "PackageData", + "ResolvedVariant", + "SolveResult", + "parse_static_package_py", + "parse_static_packages_py", + "PackageOrderer", + "register_orderer", +] + +try: + from importlib.metadata import version as _pkg_version + + __version__ = _pkg_version("pyrer") +except Exception: # pragma: no cover - version metadata is best-effort + __version__ = "unknown" + + +def solve( + package_requests, + packages=None, + /, + *, + load_family=None, + variant_select_mode="version_priority", + package_orderer=None, +): + """Resolve ``package_requests`` against a package repository. + + Args: + package_requests: rez-style requirement strings, e.g. + ``["python-3", "maya-2024"]``. + packages: a ``list[PackageData]`` (the eager repository), or + ``None`` when discovery is fully driven by ``load_family``. + load_family: optional ``Callable`` invoked on demand the first + time the solver needs a family it has not seen. See the rez + integration docs for the callback contract. + variant_select_mode: ``"version_priority"`` (rez's default) or + ``"intersection_priority"``. + package_orderer: overrides the per-family version preference. Pass + the registered **name** of a :class:`PackageOrderer` (a + ``str``), a :class:`PackageOrderer` **instance** directly, or + ``None`` for the default (highest version first). Register an + orderer with :func:`register_orderer` before selecting it by + name. + + Returns: + A :class:`SolveResult`. Failures and bad input are reported via + ``result.status``, never as a Python exception. + + Raises: + ValueError: if ``package_orderer`` is a name with no registered + orderer. + TypeError: if ``package_orderer`` is not a str / PackageOrderer / + None, or ``packages`` is the wrong type. + """ + order_fn = None + if package_orderer is not None: + if isinstance(package_orderer, str): + inst = _orderers.get(package_orderer) + if inst is None: + raise ValueError( + f"no package orderer registered as {package_orderer!r} — " + f"register one with pyrer.register_orderer()" + ) + elif isinstance(package_orderer, PackageOrderer): + inst = package_orderer + else: + raise TypeError( + "package_orderer must be a str, a PackageOrderer, or None" + ) + # Bound method (family, versions) -> reordered versions; this is + # the plain callable the Rust core consumes. + order_fn = inst.order + + return _native_solve( + package_requests, + packages, + load_family=load_family, + variant_select_mode=variant_select_mode, + package_order=order_fn, + ) diff --git a/crates/rer-python/python/pyrer/orderer.py b/crates/rer-python/python/pyrer/orderer.py new file mode 100644 index 0000000..eabd5a0 --- /dev/null +++ b/crates/rer-python/python/pyrer/orderer.py @@ -0,0 +1,92 @@ +"""Package-orderer plugin SDK for ``pyrer``. + +A *package orderer* decides, per family, which version the solver should +prefer. ``pyrer``'s default is rez's ``SortedOrder(descending=True)`` — +highest version first, using rez's native alphanumeric-token comparison. + +To override that, subclass :class:`PackageOrderer`, implement +:meth:`PackageOrderer.order`, register the class with +:func:`register_orderer`, and select it on +``pyrer.solve(..., package_orderer="")``:: + + import pyrer + + class Pep440Orderer(pyrer.PackageOrderer): + name = "pep440" + + def order(self, family, versions): + # sort `versions` however you like, most-preferred first + return sorted(versions, key=_pep440_key, reverse=True) + + pyrer.register_orderer(Pep440Orderer) + result = pyrer.solve(requests, packages, package_orderer="pep440") + +This mirrors rez's own orderer model — an SDK base class plus an explicit +registry — without rez's heavyweight plugin-manager discovery. +""" +from __future__ import annotations + +from typing import Dict, List, Union + +__all__ = ["PackageOrderer", "register_orderer"] + + +class PackageOrderer: + """Base class for a ``pyrer`` package-orderer plugin. + + Subclass it, set the class attribute :attr:`name`, and implement + :meth:`order`. Register the subclass (or an instance) with + :func:`register_orderer`, then select it by name on + ``pyrer.solve(..., package_orderer="")``. + """ + + #: Registry key. A subclass **must** set this to a non-empty string. + name: str = "" + + def order(self, family: str, versions: List[str]) -> List[str]: + """Return ``versions`` reordered, most-preferred-first. + + ``family`` is the package family name; ``versions`` is every + candidate version string the solver currently has for that family. + + The return value should be a permutation of ``versions``. ``pyrer`` + is defensive about a misbehaving orderer: a version omitted from the + result sinks to the bottom (least preferred); a version in the + result that was not in ``versions`` is ignored. The orderer is a + *preference* function — it never changes whether a solve succeeds, + only which solution is found first. + + Raising from this method propagates as a ``solve()`` result with + ``status == "error"`` — no exception escapes ``pyrer.solve``. + """ + raise NotImplementedError( + f"{type(self).__name__} must implement order()" + ) + + +# Registry: orderer name -> PackageOrderer instance. +_orderers: Dict[str, "PackageOrderer"] = {} + + +def register_orderer(orderer: Union["PackageOrderer", type]) -> None: + """Register a :class:`PackageOrderer` so it can be selected by name. + + Accepts either an instance or a :class:`PackageOrderer` *subclass* + (instantiated with no arguments). The orderer's :attr:`~PackageOrderer.name` + is the registry key; registering a second orderer under the same name + replaces the first. + + Raises: + TypeError: if `orderer` is not a `PackageOrderer` subclass/instance. + ValueError: if the orderer's `name` is empty. + """ + inst = orderer() if isinstance(orderer, type) else orderer + if not isinstance(inst, PackageOrderer): + raise TypeError( + "register_orderer expects a PackageOrderer subclass or instance" + ) + if not getattr(inst, "name", ""): + raise ValueError( + "a PackageOrderer must set a non-empty class attribute `name`" + ) + _orderers[inst.name] = inst diff --git a/crates/rer-python/src/lib.rs b/crates/rer-python/src/lib.rs index 7471607..85e5344 100644 --- a/crates/rer-python/src/lib.rs +++ b/crates/rer-python/src/lib.rs @@ -410,6 +410,41 @@ fn make_loader( ) } +/// Build a [`FamilyOrderer`] from a Python callable — the bridge for the +/// package-orderer plugin SDK. The callable is invoked once per family with +/// `(family_name, version_strings)` and must return the version strings +/// reordered, most-preferred-first. +/// +/// Shares the loader's `err_slot`: if the callable raises, the message is +/// captured there (the outer `solve()` surfaces it as `status="error"`) and +/// the loader/orderer falls back to a harmless result so the solve doesn't +/// diverge before the error is reported — here, the input order unchanged. +fn make_orderer( + callback: Py, + err_slot: Rc>>, +) -> Rc { + Rc::new(move |family: &str, versions: &[&str]| -> Vec { + let input_order = || versions.iter().map(|s| s.to_string()).collect::>(); + // A previous callback already errored — don't pile on. + if err_slot.borrow().is_some() { + return input_order(); + } + let result: PyResult> = Python::with_gil(|py| { + let ret = callback.bind(py).call1((family, versions.to_vec()))?; + ret.extract::>() + }); + match result { + Ok(ordered) => ordered, + Err(err) => { + let msg = Python::with_gil(|py| err.value(py).to_string()); + *err_slot.borrow_mut() = + Some(format!("package_orderer for {family:?} raised: {msg}")); + input_order() + } + } + }) +} + /// Inspect the Python callable's signature and return `true` if it can /// accept a second `version_range` argument — either as a named parameter /// or via `**kwargs` / `*args`. False means the legacy 1-arg shape. @@ -422,7 +457,10 @@ fn callback_takes_range(py: Python<'_>, callback: &Py) -> bool { Ok(m) => m, Err(_) => return false, }; - let sig = match inspect.getattr("signature").and_then(|f| f.call1((callback,))) { + let sig = match inspect + .getattr("signature") + .and_then(|f| f.call1((callback,))) + { Ok(s) => s, Err(_) => return false, }; @@ -457,7 +495,9 @@ fn callback_takes_range(py: Python<'_>, callback: &Py) -> bool { let Ok(name_val) = item.get_item(0) else { continue; }; - let Ok(param) = item.get_item(1) else { continue }; + let Ok(param) = item.get_item(1) else { + continue; + }; let Ok(name_s) = name_val.extract::() else { continue; }; @@ -521,6 +561,12 @@ fn parse_variant_select_mode(s: &str) -> PyResult { /// * `variant_select_mode` — either `"version_priority"` (default, rez's /// default config) or `"intersection_priority"`. Mirrors rez's /// `config.variant_select_mode`. +/// * `package_order` — Optional `Callable[[str, list[str]], list[str]]` +/// invoked once per family with `(family, version_strings)`, returning +/// the versions reordered most-preferred-first. `None` keeps the default +/// version-descending order. This is the low-level bridge for the +/// `pyrer.PackageOrderer` plugin SDK — Python callers use the +/// `package_orderer=` argument on the `pyrer.solve` wrapper instead. /// * `filters` — Optional `(filter_type, pattern)` tuples (reserved, ignored). /// * `max_iterations` — Optional iteration cap (reserved, ignored). /// @@ -536,6 +582,7 @@ fn parse_variant_select_mode(s: &str) -> PyResult { *, load_family=None, variant_select_mode="version_priority", + package_order=None, filters=None, max_iterations=None, ) )] @@ -544,6 +591,7 @@ fn solve( packages: Option>, load_family: Option>, variant_select_mode: &str, + package_order: Option>, filters: Option>, max_iterations: Option, ) -> PyResult { @@ -568,11 +616,8 @@ fn solve( // Backward-compatible: callbacks with the 1-arg shape keep // working unchanged. let takes_range = Python::with_gil(|py| callback_takes_range(py, &callback)); - let lazy = PackageRepo::with_loader(make_loader( - callback, - Rc::clone(&load_err), - takes_range, - )); + let lazy = + PackageRepo::with_loader(make_loader(callback, Rc::clone(&load_err), takes_range)); // Seed the eager set so the loader is never called for families // the caller already supplied. for (name, fam) in initial_map { @@ -583,6 +628,11 @@ fn solve( PackageRepo::from_map(initial_map) }; + // Build the pluggable package orderer from the Python callback, if one + // was supplied. Shares the same error slot as the loader — the first + // callback exception wins and surfaces as a `"error"`-status result. + let orderer = package_order.map(|cb| make_orderer(cb, Rc::clone(&load_err))); + // `Requirement::parse` panics on a syntactically invalid version range; // catch that at the FFI boundary and report it as `"error"` rather than // letting it surface as a Python `PanicException`. @@ -591,7 +641,8 @@ fn solve( .iter() .map(|s| Requirement::parse(s)) .collect(); - let mut solver = Solver::new_with_options(reqs, Rc::new(repo), make_shared_cache(), mode)?; + let mut solver = + Solver::new_with_options(reqs, Rc::new(repo), make_shared_cache(), mode, orderer)?; solver.solve(); Ok::(solver) })); @@ -770,9 +821,14 @@ fn parse_static_packages_py( .collect() } -/// The `pyrer` Python module — Rez-compatible package resolver. +/// The compiled `pyrer._native` extension — the Rust core of `pyrer`. +/// +/// `pyrer` is a mixed Rust+Python package: this module is wrapped by the +/// pure-Python `pyrer` package (`python/pyrer/`), which re-exports these +/// symbols and adds the package-orderer plugin SDK. End users +/// `import pyrer`, never `pyrer._native` directly. #[pymodule] -fn pyrer(m: &Bound<'_, PyModule>) -> PyResult<()> { +fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(solve, m)?)?; m.add_function(wrap_pyfunction!(parse_static_package_py, m)?)?; m.add_function(wrap_pyfunction!(parse_static_packages_py, m)?)?; diff --git a/crates/rer-resolver/src/rez_solver/context.rs b/crates/rer-resolver/src/rez_solver/context.rs index 3658051..12f09e9 100644 --- a/crates/rer-resolver/src/rez_solver/context.rs +++ b/crates/rer-resolver/src/rez_solver/context.rs @@ -38,8 +38,29 @@ pub type FamilyMap = HashMap; /// /// `pyrer` builds one of these from a Python callable. See the load_family /// callback in `pyrer.solve()` for the Python-side contract. -pub type FamilyLoader = - Box) -> Vec<(String, PackageData)>>; +pub type FamilyLoader = Box) -> Vec<(String, PackageData)>>; + +/// A pluggable package orderer. Given a family name and the version +/// strings of every candidate, returns those strings reordered +/// **most-preferred-first** — the order the solver should try them in. +/// +/// `rer` defaults to rez's `SortedOrder(descending=True)` (highest +/// version first) when no orderer is set. A host overrides that by +/// supplying one of these — e.g. to order by PEP 440 semantics rather +/// than rez's native alphanumeric-token comparison. +/// +/// The contract is advisory and pyrer is defensive: a version present +/// in the input but missing from the output sinks to the bottom +/// (least preferred); a version in the output that wasn't in the input +/// is ignored. The orderer is a *preference* function — it never +/// changes whether a solve succeeds, only which solution is found +/// first. +/// +/// `pyrer` builds one of these from a registered `PackageOrderer` +/// plugin. Wrapped in `Rc` so the `Solver` constructor's `build_ctx` +/// closure can clone it cheaply (mirrors how the variant cache is +/// shared). +pub type FamilyOrderer = dyn Fn(&str, &[&str]) -> Vec; /// Records which version-range was passed to the loader when a family was /// last loaded. Used by [`PackageRepo`] to decide whether a cached family @@ -73,9 +94,7 @@ impl LoadedRange { fn widened_with(&self, hint: Option<&VersionRange>) -> LoadedRange { match (self, hint) { (LoadedRange::Unconstrained, _) | (_, None) => LoadedRange::Unconstrained, - (LoadedRange::Bounded(loaded), Some(want)) => { - LoadedRange::Bounded(loaded.union(want)) - } + (LoadedRange::Bounded(loaded), Some(want)) => LoadedRange::Bounded(loaded.union(want)), } } } @@ -202,11 +221,7 @@ impl PackageRepo { /// `iter_packages(range_=...)`). The repo tracks which range each /// family was loaded under and reloads (with a widened range) when a /// later request can't be served from the cache. - pub fn get_family( - &self, - name: &str, - hint: Option<&VersionRange>, - ) -> Option> { + pub fn get_family(&self, name: &str, hint: Option<&VersionRange>) -> Option> { // Cache hit + range covered → return directly. if let Some(entry) = self.families.borrow().get(name) { if entry.loaded_range.covers(hint) { @@ -303,7 +318,6 @@ pub enum VariantSelectMode { /// and the variant cache. The cache is wrapped in `Rc>` so that it /// can be shared between solvers of the same repository — see /// [`SharedVariantCache`]. -#[derive(Debug)] pub struct SolverContext { /// The package repository, shared (never cloned per solve). pub repo: Rc, @@ -312,12 +326,32 @@ pub struct SolverContext { /// rez's `config.variant_select_mode` — selects the variant ordering /// key. Defaults to `VersionPriority` to match rez out of the box. pub variant_select_mode: VariantSelectMode, + /// Optional pluggable package orderer (see [`FamilyOrderer`]). `None` + /// means the default version-descending order. When set, it decides + /// the per-family version preference the solver explores. + pub package_order: Option>, /// Per-family variant cache. Sharing it across solves of the same repo is /// the dominant single optimisation: it skips re-parsing every variant's /// requires every solve. cache: SharedVariantCache, } +// Manual `Debug` — `Rc` (an `Rc`) is not `Debug`. +impl std::fmt::Debug for SolverContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SolverContext") + .field("repo", &self.repo) + .field("request_list", &self.request_list) + .field("variant_select_mode", &self.variant_select_mode) + .field( + "package_order", + &self.package_order.as_ref().map(|_| ""), + ) + .field("cache", &self.cache) + .finish() + } +} + impl SolverContext { /// Build a context from a repository and an already-merged request list. /// A fresh, unshared variant cache is created — every solve will rebuild @@ -329,6 +363,7 @@ impl SolverContext { repo, request_list, variant_select_mode: VariantSelectMode::default(), + package_order: None, cache: make_shared_cache(), } } @@ -345,6 +380,7 @@ impl SolverContext { repo, request_list, variant_select_mode: VariantSelectMode::default(), + package_order: None, cache, } } @@ -358,6 +394,21 @@ impl SolverContext { self } + /// Set this context's pluggable package orderer (see [`FamilyOrderer`]). + /// `None` keeps the default version-descending order. Chainable on the + /// builder-style constructors. + /// + /// Caveat — same shape as [`Self::with_variant_select_mode`]: a + /// [`SharedVariantCache`] reused across solves must use a **consistent** + /// orderer. A cached `PackageVariantList` bakes in orderer-specific + /// `order_rank` values; reusing it under a different orderer would + /// silently apply the stale ranking. `pyrer` is unaffected — it builds a + /// fresh cache per `solve()`. + pub fn with_package_order(mut self, orderer: Option>) -> Self { + self.package_order = orderer; + self + } + /// A slice of `name`'s variants intersected with `range`, or `None` if the /// family is absent or no version falls in range. Mirrors rez's /// `Solver._get_variant_slice`. diff --git a/crates/rer-resolver/src/rez_solver/mod.rs b/crates/rer-resolver/src/rez_solver/mod.rs index a91a6d0..a10bc7b 100644 --- a/crates/rer-resolver/src/rez_solver/mod.rs +++ b/crates/rer-resolver/src/rez_solver/mod.rs @@ -35,8 +35,8 @@ pub mod variant; pub type Name = std::rc::Rc; pub use context::{ - make_shared_cache, FamilyLoader, FamilyMap, PackageRepo, SharedVariantCache, SolverContext, - VariantSelectMode, + make_shared_cache, FamilyLoader, FamilyMap, FamilyOrderer, PackageRepo, SharedVariantCache, + SolverContext, VariantSelectMode, }; pub use failure::{DependencyConflict, FailureReason, SolverStatus}; pub use phase::ResolvePhase; diff --git a/crates/rer-resolver/src/rez_solver/solver.rs b/crates/rer-resolver/src/rez_solver/solver.rs index 74e7a8a..46c38cc 100644 --- a/crates/rer-resolver/src/rez_solver/solver.rs +++ b/crates/rer-resolver/src/rez_solver/solver.rs @@ -5,7 +5,7 @@ //! next step pops and archives it, then resumes the alternative phase beneath //! (the "without selection" half produced by an earlier `split`). -use super::context::{SharedVariantCache, SolverContext, VariantSelectMode}; +use super::context::{FamilyOrderer, SharedVariantCache, SolverContext, VariantSelectMode}; use super::failure::{FailureReason, SolverStatus}; use super::phase::ResolvePhase; use super::requirement::{Requirement, RequirementList}; @@ -53,17 +53,31 @@ impl Solver { repo: Rc, cache: SharedVariantCache, ) -> Result { - Self::new_with_options(package_requests, repo, cache, VariantSelectMode::default()) - } - - /// Create a solver with full control over both the shared cache and the - /// variant-select mode. Use this when you need `intersection_priority` - /// or when wiring rez's `config.variant_select_mode` through. + Self::new_with_options( + package_requests, + repo, + cache, + VariantSelectMode::default(), + None, + ) + } + + /// Create a solver with full control over the shared cache, the + /// variant-select mode, and the pluggable package orderer. Use this + /// when you need `intersection_priority`, a custom + /// [`FamilyOrderer`](super::context::FamilyOrderer), or when wiring + /// rez's `config` through. + /// + /// `package_order` is `None` for the default version-descending order. + /// When sharing `cache` across solves, the orderer (like the + /// variant-select mode) must be consistent — see + /// [`SolverContext::with_package_order`]. pub fn new_with_options( package_requests: Vec, repo: Rc, cache: SharedVariantCache, variant_select_mode: VariantSelectMode, + package_order: Option>, ) -> Result { let request_list = RequirementList::new(package_requests); @@ -71,7 +85,8 @@ impl Solver { |repo: Rc, request_list: RequirementList| -> Rc { Rc::new( SolverContext::new_with_cache(repo, request_list, Rc::clone(&cache)) - .with_variant_select_mode(variant_select_mode), + .with_variant_select_mode(variant_select_mode) + .with_package_order(package_order.clone()), ) }; @@ -285,6 +300,25 @@ mod tests { solver } + /// Solve with a pluggable package orderer (issue: package-orderer plugin). + fn solve_with_orderer( + repo: PackageRepo, + requests: &[&str], + orderer: Rc, + ) -> Solver { + let reqs = requests.iter().map(|s| Requirement::parse(s)).collect(); + let mut solver = Solver::new_with_options( + reqs, + Rc::new(repo), + crate::rez_solver::make_shared_cache(), + crate::rez_solver::VariantSelectMode::default(), + Some(orderer), + ) + .expect("solver construction"); + solver.solve(); + solver + } + fn resolved_set(solver: &Solver) -> Vec<(String, String)> { let mut out: Vec<(String, String)> = solver .resolved_packages() @@ -306,18 +340,20 @@ mod tests { let calls: Rc>> = Rc::new(RefCell::new(Vec::new())); let calls_inner = Rc::clone(&calls); - let repo = crate::rez_solver::PackageRepo::with_loader(Box::new(move |name: &str, _hint: Option<&rer_version::VersionRange>| { - calls_inner.borrow_mut().push(name.to_string()); - match name { - "app" => vec![("1.0".to_string(), pkg(&["lib-2"], &[]))], - "lib" => vec![ - ("1.0".to_string(), pkg(&[], &[])), - ("2.0".to_string(), pkg(&[], &[])), - ], - "unrelated" => vec![("1.0".to_string(), pkg(&[], &[]))], - _ => Vec::new(), - } - })); + let repo = crate::rez_solver::PackageRepo::with_loader(Box::new( + move |name: &str, _hint: Option<&rer_version::VersionRange>| { + calls_inner.borrow_mut().push(name.to_string()); + match name { + "app" => vec![("1.0".to_string(), pkg(&["lib-2"], &[]))], + "lib" => vec![ + ("1.0".to_string(), pkg(&[], &[])), + ("2.0".to_string(), pkg(&[], &[])), + ], + "unrelated" => vec![("1.0".to_string(), pkg(&[], &[]))], + _ => Vec::new(), + } + }, + )); let reqs = vec![Requirement::parse("app")]; let mut solver = Solver::new(reqs, Rc::new(repo)).expect("solver construction"); @@ -344,15 +380,17 @@ mod tests { // A diamond: app -> lib & util; util -> lib. lib is reached twice // but the loader must only be invoked once. - let repo = crate::rez_solver::PackageRepo::with_loader(Box::new(move |name: &str, _hint: Option<&rer_version::VersionRange>| { - calls_inner.borrow_mut().push(name.to_string()); - match name { - "app" => vec![("1.0".into(), pkg(&["lib", "util"], &[]))], - "util" => vec![("1.0".into(), pkg(&["lib"], &[]))], - "lib" => vec![("1.0".into(), pkg(&[], &[]))], - _ => Vec::new(), - } - })); + let repo = crate::rez_solver::PackageRepo::with_loader(Box::new( + move |name: &str, _hint: Option<&rer_version::VersionRange>| { + calls_inner.borrow_mut().push(name.to_string()); + match name { + "app" => vec![("1.0".into(), pkg(&["lib", "util"], &[]))], + "util" => vec![("1.0".into(), pkg(&["lib"], &[]))], + "lib" => vec![("1.0".into(), pkg(&[], &[]))], + _ => Vec::new(), + } + }, + )); let reqs = vec![Requirement::parse("app")]; let mut solver = Solver::new(reqs, Rc::new(repo)).expect("solver construction"); @@ -444,7 +482,9 @@ mod tests { fn test_loader_empty_means_missing_family() { // The loader returns no entries for an unknown name; the solver // treats that as a missing family (failed resolve), not a panic. - let repo = crate::rez_solver::PackageRepo::with_loader(Box::new(|_: &str, _: Option<&rer_version::VersionRange>| Vec::new())); + let repo = crate::rez_solver::PackageRepo::with_loader(Box::new( + |_: &str, _: Option<&rer_version::VersionRange>| Vec::new(), + )); let reqs = vec![Requirement::parse("doesnotexist")]; let solver = Solver::new(reqs, Rc::new(repo)); // Either Solver::new returns a ScopeError or the solve fails; @@ -458,6 +498,94 @@ mod tests { } } + // --- package-orderer plugin ----------------------------------------- + + #[test] + fn test_orderer_reverses_preference() { + // Default order prefers the highest version. An orderer that + // returns versions lowest-first flips that — the solver should + // resolve foo-1.0, not foo-3.0. + let r = repo(vec![( + "foo", + vec![ + ("1.0", pkg(&[], &[])), + ("2.0", pkg(&[], &[])), + ("3.0", pkg(&[], &[])), + ], + )]); + let orderer: Rc = + Rc::new(|_family: &str, versions: &[&str]| { + let mut v: Vec = versions.iter().map(|s| s.to_string()).collect(); + v.sort(); // ascending — lowest version most preferred + v + }); + let solver = solve_with_orderer(r, &["foo"], orderer); + assert_eq!(solver.status(), SolverStatus::Solved); + assert_eq!(resolved_set(&solver), vec![("foo".into(), "1.0".into())]); + } + + #[test] + fn test_orderer_pins_a_version() { + // An orderer that puts "2.0" first makes the solver resolve + // foo-2.0 even though 3.0 exists. + let r = repo(vec![( + "foo", + vec![ + ("1.0", pkg(&[], &[])), + ("2.0", pkg(&[], &[])), + ("3.0", pkg(&[], &[])), + ], + )]); + let orderer: Rc = + Rc::new(|_family: &str, versions: &[&str]| { + let mut v: Vec = vec!["2.0".to_string()]; + v.extend( + versions + .iter() + .filter(|s| **s != "2.0") + .map(|s| s.to_string()), + ); + v + }); + let solver = solve_with_orderer(r, &["foo"], orderer); + assert_eq!(resolved_set(&solver), vec![("foo".into(), "2.0".into())]); + } + + #[test] + fn test_orderer_partial_output_no_panic() { + // An orderer that names only one version must not panic — the + // omitted versions sink to the bottom. With only "1.0" named, + // 1.0 is most preferred → resolved. + let r = repo(vec![( + "foo", + vec![ + ("1.0", pkg(&[], &[])), + ("2.0", pkg(&[], &[])), + ("3.0", pkg(&[], &[])), + ], + )]); + let orderer: Rc = + Rc::new(|_family: &str, _versions: &[&str]| vec!["1.0".to_string()]); + let solver = solve_with_orderer(r, &["foo"], orderer); + assert_eq!(resolved_set(&solver), vec![("foo".into(), "1.0".into())]); + } + + #[test] + fn test_no_orderer_prefers_highest() { + // Control: with no orderer, the default order resolves the + // highest version. Guards the default rank branch. + let r = repo(vec![( + "foo", + vec![ + ("1.0", pkg(&[], &[])), + ("2.0", pkg(&[], &[])), + ("3.0", pkg(&[], &[])), + ], + )]); + let solver = solve(r, &["foo"]); + assert_eq!(resolved_set(&solver), vec![("foo".into(), "3.0".into())]); + } + #[test] fn test_trivial_single_package() { let solver = solve(repo(vec![("foo", vec![("1.0", pkg(&[], &[]))])]), &["foo"]); diff --git a/crates/rer-resolver/src/rez_solver/variant.rs b/crates/rer-resolver/src/rez_solver/variant.rs index b65f39d..cd42c17 100644 --- a/crates/rer-resolver/src/rez_solver/variant.rs +++ b/crates/rer-resolver/src/rez_solver/variant.rs @@ -226,6 +226,12 @@ pub struct PackageEntry { variants: Vec>, /// Whether `variants` is in rez's preferred (descending key) order. sorted: bool, + /// This version's rank in the family's preference order — 0 is the + /// most-preferred version. Computed once per family in + /// [`PackageVariantList::new`] (default: version-descending; or via + /// the pluggable [`FamilyOrderer`](super::context::FamilyOrderer)). + /// `PackageVariantSlice::sort_versions` sorts by this. + order_rank: usize, } impl PackageEntry { @@ -278,11 +284,13 @@ impl PackageEntry { version: self.version.clone(), variants: self.variants[..nvariants].to_vec(), sorted: true, + order_rank: self.order_rank, }; let next_entry = PackageEntry { version: self.version.clone(), variants: self.variants[nvariants..].to_vec(), sorted: true, + order_rank: self.order_rank, }; Some((entry, next_entry)) } @@ -334,6 +342,41 @@ fn variant_sort_key(variant: &PackageVariant, ctx: &SolverContext) -> VariantKey // PackageVariantList — every variant of a family (cached per family) // --------------------------------------------------------------------------- +/// Turn a [`FamilyOrderer`](super::context::FamilyOrderer)'s output into a +/// `version_str -> rank` map (0 = most preferred). This is the single +/// enforcement point for the orderer's advisory contract — a misbehaving +/// orderer can never panic the solver: +/// +/// - A version named in `ordered` keeps its **first-seen** position as its +/// rank (a duplicate in `ordered` uses the first occurrence). +/// - A version in `input` the orderer **omitted** sinks to the bottom — it +/// gets a rank worse than every named version, ties broken by `input` +/// order (deterministic). +/// - A version in `ordered` **not in `input`** is ignored. +/// +/// Every `input` version ends up with exactly one rank. +fn build_rank_map(input: &[&str], ordered: Vec) -> FxHashMap { + let input_set: FxHashSet<&str> = input.iter().copied().collect(); + let mut ranks: FxHashMap = FxHashMap::default(); + ranks.reserve(input.len()); + let mut next_rank = 0usize; + // Named-by-the-orderer versions, in the orderer's order. + for v in &ordered { + if input_set.contains(v.as_str()) && !ranks.contains_key(v.as_str()) { + ranks.insert(v.clone(), next_rank); + next_rank += 1; + } + } + // Omitted versions sink to the bottom, keeping input order. + for v in input { + if !ranks.contains_key(*v) { + ranks.insert((*v).to_string(), next_rank); + next_rank += 1; + } + } + ranks +} + /// One version of a family, whose `PackageEntry` (parsed requirements) is /// materialised lazily on first access. #[derive(Debug)] @@ -341,6 +384,9 @@ struct LazyEntry { version: RerVersion, /// The repository key for this version, used to look its data back up. version_str: String, + /// Preference rank for this version — 0 is most preferred. Stamped + /// onto the `PackageEntry` built from this `LazyEntry`. + order_rank: usize, /// `None` until the version is first touched by a range intersection; /// thereafter the built `Rc` — unsorted, and shared (by /// `Rc`) with every slice that intersects this version. @@ -390,12 +436,38 @@ impl PackageVariantList { LazyEntry { version, version_str: version_str.clone(), + order_rank: 0, // assigned below entry: RefCell::new(None), } }) .collect(); entries.sort_by(|a, b| a.version.cmp(&b.version)); + // Assign preference ranks (`order_rank` 0 = most preferred). The + // orderer is (re-)invoked on every (re)build of this list — including + // the widened-range reload path of issue #92 — so ranks are always + // recomputed wholesale; no stale ranks survive. + match &ctx.package_order { + None => { + // Default — rez's `SortedOrder(descending=True)`: highest + // version most preferred. `entries` is ascending, so the + // rank is the reversed index. + let n = entries.len(); + for (i, e) in entries.iter_mut().enumerate() { + e.order_rank = n - 1 - i; + } + } + Some(orderer) => { + let version_strs: Vec<&str> = + entries.iter().map(|e| e.version_str.as_str()).collect(); + let ordered = orderer(package_name, &version_strs); + let ranks = build_rank_map(&version_strs, ordered); + for e in entries.iter_mut() { + e.order_rank = ranks[e.version_str.as_str()]; + } + } + } + Some(PackageVariantList { package_name: Name::from(package_name), versions, @@ -420,6 +492,7 @@ impl PackageVariantList { version: lazy.version.clone(), variants: build_variants(&self.package_name, &lazy.version, data), sorted: false, + order_rank: lazy.order_rank, }) }); out.push(Rc::clone(built)); @@ -515,7 +588,7 @@ pub struct PackageVariantSlice { entries: Vec>, /// Families already extracted from this slice as common requirements. extracted_fams: FxHashSet, - /// Whether `entries` is version-sorted (descending). + /// Whether `entries` is in preference order (`sort_versions` applied). sorted: bool, // Lazily-computed, entries-derived caches. len_cache: OnceCell, @@ -694,6 +767,7 @@ impl PackageVariantSlice { version: entry.version.clone(), variants: kept, sorted: entry.sorted, + order_rank: entry.order_rank, })); } else { // Unchanged — share the entry rather than deep-cloning it. @@ -831,12 +905,18 @@ impl PackageVariantSlice { ) } - /// Sort entries by version, descending. Idempotent. + /// Sort entries into the family's preference order — most-preferred + /// version first. By default that is version-descending (rez's + /// `SortedOrder`); with a pluggable + /// [`FamilyOrderer`](super::context::FamilyOrderer) it is whatever + /// order that orderer produced. The preference is baked into each + /// entry's `order_rank` (0 = most preferred) by + /// [`PackageVariantList::new`]. Idempotent. pub fn sort_versions(&mut self) { if self.sorted { return; } - self.entries.sort_by(|a, b| b.version.cmp(&a.version)); + self.entries.sort_by(|a, b| a.order_rank.cmp(&b.order_rank)); self.sorted = true; } @@ -984,6 +1064,57 @@ mod tests { use super::*; use crate::PackageData; + // --- build_rank_map (package-orderer plugin) ------------------------ + + fn ord(strs: &[&str]) -> Vec { + strs.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn rank_map_permutation() { + // A clean reorder — ranks follow the orderer's output order. + let m = build_rank_map(&["1", "2", "3"], ord(&["3", "1", "2"])); + assert_eq!(m["3"], 0); + assert_eq!(m["1"], 1); + assert_eq!(m["2"], 2); + } + + #[test] + fn rank_map_omitted_versions_sink() { + // "2" is the only named version; "1" and "3" sink to the bottom + // in input order. + let m = build_rank_map(&["1", "2", "3"], ord(&["2"])); + assert_eq!(m["2"], 0); + assert_eq!(m["1"], 1); + assert_eq!(m["3"], 2); + } + + #[test] + fn rank_map_unknown_versions_ignored() { + // "9" was never an input version — it's dropped, not ranked. + let m = build_rank_map(&["1", "2"], ord(&["9", "1", "2"])); + assert_eq!(m.len(), 2); + assert_eq!(m["1"], 0); + assert_eq!(m["2"], 1); + } + + #[test] + fn rank_map_duplicate_uses_first_occurrence() { + let m = build_rank_map(&["1", "2"], ord(&["1", "1", "2"])); + assert_eq!(m["1"], 0); + assert_eq!(m["2"], 1); + } + + #[test] + fn rank_map_empty_output_keeps_input_order() { + // An orderer that returns nothing → every version keeps input + // order, all ranked, no panic. + let m = build_rank_map(&["1", "2", "3"], ord(&[])); + assert_eq!(m["1"], 0); + assert_eq!(m["2"], 1); + assert_eq!(m["3"], 2); + } + fn pkg(requires: &[&str], variants: &[&[&str]]) -> PackageData { PackageData { requires: requires.iter().map(|s| s.to_string()).collect(), diff --git a/docs/content/docs/getting-started/rez-integration.md b/docs/content/docs/getting-started/rez-integration.md index 1e1637c..ce05bf1 100644 --- a/docs/content/docs/getting-started/rez-integration.md +++ b/docs/content/docs/getting-started/rez-integration.md @@ -821,6 +821,63 @@ No Python exception is raised from a failed or errored solve — both are reported via `result.status`. Only a `TypeError` is raised, and only when the `packages` argument is not a list of `PackageData`. +## Custom package ordering + +By default `pyrer` prefers the **highest** version of a family — +rez's `SortedOrder(descending=True)`, using rez's native +alphanumeric-token version comparison. + +A studio that runs a custom rez orderer (registered via rez's +`register_orderer()` / `config.package_orderers`) needs `pyrer` to +order versions the same way, or the two solvers pick different +versions of the same family. `pyrer` exposes a parallel **plugin +SDK** for this: subclass `pyrer.PackageOrderer`, implement +`order()`, register it, and select it on `solve()`. + +```python +import pyrer + + +class Pep440Orderer(pyrer.PackageOrderer): + """Order versions by PEP 440 semantics instead of rez-native.""" + + name = "pep440" + + def order(self, family, versions): + # `versions` is every candidate version string for `family`. + # Return them most-preferred-first — here, sorted as PEP 440. + return sorted(versions, key=_pep440_key, reverse=True) + + +pyrer.register_orderer(Pep440Orderer) + +result = pyrer.solve(requests, packages, package_orderer="pep440") +``` + +The SDK contract: + +- **`name`** — a class attribute; the registry key. `register_orderer` + rejects an orderer with an empty `name`. +- **`order(family, versions) -> list[str]`** — return `versions` + reordered, most-preferred-first. It should be a permutation; + `pyrer` is defensive — a version omitted from the result sinks to + the bottom (least preferred), a version not in `versions` is + ignored. The orderer is consulted **once per family**. +- An orderer is a **preference** function: it changes which solution + is found, never whether a solve succeeds. +- If `order()` raises, the solve returns `status == "error"` with the + message in `failure_description` — no exception escapes + `pyrer.solve`. + +`package_orderer` accepts the registered **name** (a `str`), a +`PackageOrderer` **instance** directly, or `None` (the default). + +This mirrors rez's own orderer model — an SDK base class plus an +explicit registry — without rez's heavyweight plugin-manager +discovery. To match a rez studio's resolves, port that studio's +`PackageOrder` subclass: the `sort_key` logic copies almost verbatim +into `order()` (wrap it in `sorted(versions, key=..., reverse=True)`). + ## Translating the result back to `rez` `pyrer.ResolvedVariant` objects already expose the attribute surface diff --git a/tests/test_rich_api.py b/tests/test_rich_api.py index 0a312bc..01e27fe 100644 --- a/tests/test_rich_api.py +++ b/tests/test_rich_api.py @@ -905,3 +905,125 @@ def test_parse_static_package_py_roundtrips_through_solve(): assert result_parsed.status == "solved" assert result_constructed.status == "solved" assert result_parsed.resolved == result_constructed.resolved + + +# --------------------------------------------------------------------------- +# package orderer plugin — pyrer.PackageOrderer + register_orderer +# --------------------------------------------------------------------------- + + +def _three_versions(name): + """A family `name` with versions 1.0.0 / 2.0.0 / 3.0.0, no deps.""" + return [_pkg(name, "1.0.0"), _pkg(name, "2.0.0"), _pkg(name, "3.0.0")] + + +def test_package_orderer_default_prefers_highest(): + """No orderer → rez-default order → highest version resolved.""" + result = pyrer.solve(["foo"], _three_versions("foo")) + assert result.status == "solved" + assert result.resolved_packages[0].version == "3.0.0" + + +def test_package_orderer_reverses_preference(): + """An orderer that sorts ascending makes the solver pick the lowest + version. Selected by registered name.""" + + class LowestFirst(pyrer.PackageOrderer): + name = "test-lowest-first" + + def order(self, family, versions): + return sorted(versions) # ascending — lowest most preferred + + pyrer.register_orderer(LowestFirst) + result = pyrer.solve( + ["foo"], _three_versions("foo"), package_orderer="test-lowest-first" + ) + assert result.status == "solved" + assert result.resolved_packages[0].version == "1.0.0" + + +def test_package_orderer_accepts_instance_directly(): + """`package_orderer` also takes a PackageOrderer instance, not just a + registered name.""" + + class PinTwo(pyrer.PackageOrderer): + name = "test-pin-two" + + def order(self, family, versions): + rest = [v for v in versions if v != "2.0.0"] + return ["2.0.0", *rest] + + result = pyrer.solve(["foo"], _three_versions("foo"), package_orderer=PinTwo()) + assert result.status == "solved" + assert result.resolved_packages[0].version == "2.0.0" + + +def test_package_orderer_unknown_name_raises(): + """Selecting an unregistered name is a caller error.""" + import pytest + + with pytest.raises(ValueError, match="no package orderer registered"): + pyrer.solve(["foo"], _three_versions("foo"), package_orderer="does-not-exist") + + +def test_package_orderer_wrong_type_raises(): + import pytest + + with pytest.raises(TypeError, match="package_orderer must be"): + pyrer.solve(["foo"], _three_versions("foo"), package_orderer=42) + + +def test_register_orderer_without_name_raises(): + """A PackageOrderer subclass must set a non-empty `name`.""" + import pytest + + class Nameless(pyrer.PackageOrderer): + def order(self, family, versions): + return versions + + with pytest.raises(ValueError, match="non-empty"): + pyrer.register_orderer(Nameless) + + +def test_register_orderer_rejects_non_orderer(): + import pytest + + with pytest.raises(TypeError, match="PackageOrderer"): + pyrer.register_orderer(object()) + + +def test_package_orderer_exception_surfaces_as_error(): + """If `order()` raises, the solve returns status='error' — no + exception escapes pyrer.solve.""" + + class Boom(pyrer.PackageOrderer): + name = "test-boom" + + def order(self, family, versions): + raise RuntimeError("orderer blew up") + + result = pyrer.solve(["foo"], _three_versions("foo"), package_orderer=Boom()) + assert result.status == "error" + assert "orderer blew up" in (result.failure_description or "") + + +def test_package_orderer_partial_output_no_crash(): + """An orderer naming only some versions must not crash — omitted + versions sink to the bottom (least preferred).""" + + class OnlyOne(pyrer.PackageOrderer): + name = "test-only-one" + + def order(self, family, versions): + return ["1.0.0"] # omits 2.0.0, 3.0.0 + + result = pyrer.solve(["foo"], _three_versions("foo"), package_orderer=OnlyOne()) + assert result.status == "solved" + assert result.resolved_packages[0].version == "1.0.0" + + +def test_package_orderer_none_is_default(): + """package_orderer=None is identical to omitting it.""" + a = pyrer.solve(["foo"], _three_versions("foo")) + b = pyrer.solve(["foo"], _three_versions("foo"), package_orderer=None) + assert a.resolved == b.resolved