diff --git a/README.md b/README.md index 04fab00..e9d398c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ construction, and the whole `ResolvedContext` lifecycle. To plug `pyrer` in behind a normal `rez env` / `ResolvedContext` flow: 1. Walk rez's package paths into `pyrer.PackageData` objects — once - per process, reusable across many solves: + per process, reusable across many solves. `PackageData.from_rez(pkg)` + does the per-package conversion (stringifies `version` and each + `Requirement`) so the integration shim is one line: ```python import pyrer @@ -118,12 +120,7 @@ construction, and the whole `ResolvedContext` lifecycle. To plug def build_pyrer_packages(package_paths): for fam in iter_package_families(paths=package_paths): for pkg in fam.iter_packages(): - yield pyrer.PackageData( - name=fam.name, - version=str(pkg.version), - requires=[str(r) for r in (pkg.requires or [])], - variants=[[str(r) for r in v] for v in (pkg.variants or [])], - ) + yield pyrer.PackageData.from_rez(pkg) ``` 2. Call `pyrer.solve(requests, list(build_pyrer_packages(paths)))` diff --git a/crates/rer-python/src/lib.rs b/crates/rer-python/src/lib.rs index e919e7b..f7cfbcd 100644 --- a/crates/rer-python/src/lib.rs +++ b/crates/rer-python/src/lib.rs @@ -11,6 +11,7 @@ //! (`name`, `version`, `variant_index`, `requires`, `uri`). use pyo3::prelude::*; +use pyo3::types::PyType; use rer_resolver::rez_solver::{PackageRepo, Requirement, ScopeError, Solver, SolverStatus}; use std::collections::HashMap; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -65,6 +66,59 @@ impl PackageData { self.variants.len() ) } + + /// Build a [`PackageData`] from a rez `Package` (or anything duck-typed + /// with the same four attributes — `name`, `version`, `requires`, + /// `variants`). + /// + /// Stringifies each requirement (rez's `Requirement` instances render + /// as the rez requirement string via `__str__`) and the `version` (a + /// `rez.version.Version` is not a `str` on its own). `None` for either + /// `requires` or `variants` is treated as empty. + /// + /// This is a convenience over the four-field constructor — it lives in + /// `pyrer` so every integration site doesn't have to write the same + /// extraction loop. `pyrer` itself does not import rez; this method is + /// duck-typed and works against any object with the four attributes. + #[classmethod] + fn from_rez(_cls: &Bound<'_, PyType>, pkg: &Bound<'_, PyAny>) -> PyResult { + let name: String = pkg.getattr("name")?.extract()?; + let version: String = pkg.getattr("version")?.str()?.extract()?; + let requires = read_requirement_list(&pkg.getattr("requires")?)?; + let variants = read_variants_list(&pkg.getattr("variants")?)?; + Ok(PackageData { + name, + version, + requires, + variants, + }) + } +} + +/// Pull a flat list of requirement strings from a Python object that is +/// either `None`, a sequence of strings, or a sequence of rez-style +/// `Requirement` objects (anything whose `__str__` is the rez requirement +/// form). Used by `PackageData::from_rez` for both the top-level `requires` +/// and each entry of `variants`. +fn read_requirement_list(obj: &Bound<'_, PyAny>) -> PyResult> { + if obj.is_none() { + return Ok(Vec::new()); + } + obj.try_iter()? + .map(|item| item?.str()?.extract::()) + .collect() +} + +/// Pull `list[list[str]]` from a Python `variants` attribute that is +/// `None`, an empty list, or a sequence of sequences of `Requirement`s / +/// strings. +fn read_variants_list(obj: &Bound<'_, PyAny>) -> PyResult>> { + if obj.is_none() { + return Ok(Vec::new()); + } + obj.try_iter()? + .map(|inner| read_requirement_list(&inner?)) + .collect() } // --------------------------------------------------------------------------- diff --git a/docs/content/docs/getting-started/rez-integration.md b/docs/content/docs/getting-started/rez-integration.md index f6350b1..8335a9e 100644 --- a/docs/content/docs/getting-started/rez-integration.md +++ b/docs/content/docs/getting-started/rez-integration.md @@ -56,8 +56,8 @@ The minimum integration looks like: ## Building the `pyrer` repo from `rez` `pyrer.solve()` accepts a Python list of `pyrer.PackageData` objects — -one per (package, version). Build them straight off rez's loaded -packages, no JSON serialisation needed: +one per (package, version). Use `PackageData.from_rez(pkg)` to convert +each rez `Package` in one line: ```python import pyrer @@ -68,17 +68,17 @@ def build_pyrer_packages(package_paths): """Walk rez's package paths and yield pyrer.PackageData instances.""" for family in iter_package_families(paths=package_paths): for pkg in family.iter_packages(): - yield pyrer.PackageData( - name=family.name, - version=str(pkg.version), - requires=[str(r) for r in (pkg.requires or [])], - variants=[ - [str(r) for r in variant] - for variant in (pkg.variants or []) - ], - ) + yield pyrer.PackageData.from_rez(pkg) ``` +`from_rez(pkg)` reads `name`, `version`, `requires` and `variants` +off the rez `Package`, stringifies each `Requirement` (rez's +`Requirement` instances are not `str` on their own — they render via +`__str__`), and stringifies `version` (a `rez.version.Version`). It +is duck-typed — `pyrer` itself does not import rez — so you can also +pass any object exposing the same four attributes (e.g. a test +fixture). + Two notes on this step: - It is **eager** — every package on every path is loaded. `rez` diff --git a/tests/test_rich_api.py b/tests/test_rich_api.py index 09949aa..c229a72 100644 --- a/tests/test_rich_api.py +++ b/tests/test_rich_api.py @@ -135,3 +135,110 @@ def test_solveresult_repr_uses_resolved_packages_count(): result = pyrer.solve(["foo"], [_pkg("foo", "1.0.0")]) r = repr(result) assert "SolveResult" in r and "1 packages" in r + + +# --------------------------------------------------------------------------- +# PackageData.from_rez — duck-typed convenience for rez integration +# --------------------------------------------------------------------------- + + +def test_from_rez_plain_attributes(): + """Anything duck-typed with name/version/requires/variants works.""" + + class Pkg: + name = "maya" + version = "2024.0" + requires = ["python-3"] + variants = [["python-3.10"], ["python-3.11"]] + + pd = pyrer.PackageData.from_rez(Pkg()) + assert pd.name == "maya" + assert pd.version == "2024.0" + assert pd.requires == ["python-3"] + assert pd.variants == [["python-3.10"], ["python-3.11"]] + + +def test_from_rez_none_collections_become_empty(): + """rez packages with no requires / no variants come through as `None`.""" + + class Pkg: + name = "foo" + version = "1.0.0" + requires = None + variants = None + + pd = pyrer.PackageData.from_rez(Pkg()) + assert pd.requires == [] + assert pd.variants == [] + + +def test_from_rez_stringifies_requirement_objects(): + """rez's `Requirement` objects are not strings — they render via __str__.""" + + class FakeReq: + def __init__(self, s): + self._s = s + + def __str__(self): + return self._s + + class Pkg: + name = "tool" + version = "1.0.0" + requires = [FakeReq("python-3"), FakeReq("qt-5")] + variants = [[FakeReq("linux"), FakeReq("python-3.10")]] + + pd = pyrer.PackageData.from_rez(Pkg()) + assert pd.requires == ["python-3", "qt-5"] + assert pd.variants == [["linux", "python-3.10"]] + + +def test_from_rez_stringifies_version(): + """rez's `Version` is not a `str`; from_rez calls `str(version)` for us.""" + + class V: + def __str__(self): + return "2024.0" + + class Pkg: + name = "maya" + version = V() + requires = None + variants = None + + pd = pyrer.PackageData.from_rez(Pkg()) + assert pd.version == "2024.0" + + +def test_from_rez_missing_attribute_raises_attributeerror(): + """A non-Package object missing one of the four attributes is a user bug.""" + import pytest + + class NotAPackage: + name = "x" + # no `version`, no `requires`, no `variants` + + with pytest.raises(AttributeError): + pyrer.PackageData.from_rez(NotAPackage()) + + +def test_from_rez_used_in_solve(): + """End-to-end: from_rez → solve produces the same result as constructor.""" + + class Pkg: + def __init__(self, name, version, requires=None, variants=None): + self.name = name + self.version = version + self.requires = requires + self.variants = variants + + fakes = [ + Pkg("app", "1.0.0", requires=["lib-2"]), + Pkg("lib", "1.0.0"), + Pkg("lib", "2.0.0"), + ] + packages = [pyrer.PackageData.from_rez(p) for p in fakes] + result = pyrer.solve(["app"], packages) + assert result.status == "solved" + names = {v.name for v in result.resolved_packages} + assert names == {"app", "lib"}