Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)))`
Expand Down
54 changes: 54 additions & 0 deletions crates/rer-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self> {
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<Vec<String>> {
if obj.is_none() {
return Ok(Vec::new());
}
obj.try_iter()?
.map(|item| item?.str()?.extract::<String>())
.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<Vec<Vec<String>>> {
if obj.is_none() {
return Ok(Vec::new());
}
obj.try_iter()?
.map(|inner| read_requirement_list(&inner?))
.collect()
}

// ---------------------------------------------------------------------------
Expand Down
22 changes: 11 additions & 11 deletions docs/content/docs/getting-started/rez-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
107 changes: 107 additions & 0 deletions tests/test_rich_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Loading