Rez package authoring rules
One-sentence summary:
package.pyis a declaration of what a package needs and provides — decide the version/variant split first, then keep requirement ranges as wide as the truth allows and as narrow as reality demands.
Skill scope: how to decide and structure a package. For the attribute reference and
@early/@late mechanics see rez-package-definition; for the commands() body and rex API see
rez-package-commands; for build/release command flags see rez-cli; for what rez does to the
module scope of package.py — serialization, build-time freezing and the errors that follow — see
rez-package-pitfalls.
The three decisions, in order
Get these right and the file writes itself.
- Is this a new version or a new variant? — something about the package changed vs. something about its dependencies changed.
- How wide is each dependency range? — what the package genuinely works with, not what it happened to be built against.
- What is build-time vs. runtime? — which requirements survive into the installed package.
Decision 1 — new version or new variant?
| The change | Do this |
|---|---|
| Your own source code changed | new version |
| A dependency version it must build against changed (maya 2016 → 2017, python 2 → 3) | new variant |
| Need to support a second platform/arch/os | new variant |
| Fixing a bug in the payload | new version |
The rule that catches people out: variants cannot be added to a package that has none without
bumping the version. A package released with no variants at all can never gain them. So if a
compiled package plausibly needs variants later — a second python, a second DCC — declare the single
variant now:
name = "my_utils"
variants = [
["python-2.7"],
]
This is the "future proofing" reason for single-variant packages. The second common reason is
cosmetic but real: variant requirements appear in the install path, so
my_utils/1.0.0/python-2.7/<payload> tells a user what they are getting, where
my_utils/1.0.0/<payload> does not.
Variants are per-version, not per-package. Adding a variant means re-releasing that version; it does not retroactively change already-released versions.
Decision 2 — how wide should a range be?
Start from the truth about compatibility, then express it with the narrowest syntax that still says it.
| You mean | Write |
|---|---|
| any version | foo |
any 1.x |
foo-1 |
1.0.0 or newer |
foo-1+ |
at least 1.2, below 2 |
foo-1.2+<2 |
exactly 2.0.0, nothing else |
foo==2.0.0 |
1.3 or 5+, but not between |
foo-1.3|5+ |
| if present it must be in range, but don't require it | ~foo-2.7.3 |
| this version is unacceptable | !foo-2015.6 |
Three rules for sound ranges:
-
Do not pin what you do not have to pin.
python==2.7.11forces every environment that uses your package onto that exact build;python-2.7lets rez co-exist with everything else. Pin only when there is a concrete incompatibility. -
Do not widen past what you tested. A range is a promise.
foo-1+claims you work with every futurefoo-1. -
Build time can be wider than runtime. This is what requirements expansion is for. A C++ package may build against any
boost-1but must link at runtime against the minor version it was built with. Write the loose range and let rez expand it:requires = [ "boost-1.*", # expands to the version actually built against, e.g. boost-1.55 ]**expands to the full version (boost-1.**→boost-1.55.1). The equivalent done by hand:@early() def requires(): from rez.package_py_utils import expand_requires return expand_requires(["boost-1.*"]) -
Use
~(weak reference) when you constrain but do not require. DCCs that ship an embedded python should declarerequires = ["~python-2.7.3"]: python is not pulled into the environment, but if something else brings python in, it has to be the compatible one. This is how a studio keeps python libraries usable both inside and outside the DCC.
Decision 3 — runtime vs. build time
| Attribute | Puts into the environment at | Transitive? |
|---|---|---|
requires |
runtime and build | yes |
build_requires |
build only | yes — collects from every package in the build env |
private_build_requires |
build only | no — only from the package being built |
variants |
build (one variant at a time) and runtime (the selected variant) | n/a |
Choose build_requires when other packages also need the dependency to build against your headers
— a header-only C++ library is the canonical case: if your public headers include its headers, anyone
compiling against you needs it too. Choose private_build_requires for things nobody downstream
needs: doc generators (doxygen, sphinx), build utilities, statically linked libraries.
The build environment is assembled in a fixed order — requires, then transitive build_requires,
then private_build_requires, then the current variant's requirements.
A subtle trap with building: early-bound functions are evaluated more than once per build —
once pre-build, and once per variant — and the pre-build value (where building is False) is
the one baked into the installed package. So to add a requirement at runtime only:
@early()
def requires():
if building:
return ["python-2"]
else:
return ["runtimeonly-1.2", "python-2"]
Always check the function returns what you want when building is False.
Writing commands()
Rez is not a build system and does not know how your package is consumed. commands() is where you
say it. Keep it declarative — remember it is interpreted into shell code, not executed as Python.
def commands():
env.PATH.append("{root}/bin")
env.PYTHONPATH.append("{root}/python")
if building:
env.CMAKE_MODULE_PATH.append("{root}/cmake")
Rules:
- Put build information behind
if building:so consumers find headers and CMake config at build time without polluting the runtime environment. commands()never runs for the package being built. Usepre_build_commands()for that.- Expose tools with
tools, not by hand-managingPATH, sorez-context --toolsand the tool conflict detection work. - Keep it cheap. Anything that can be computed once should be an
@early()attribute; use@late()only for values that genuinely depend on runtime state (env vars, user role).
The build/release loop
rez-build --install # build all variants, install to ~/packages
rez-env mypackage # resolve and test in a separate shell
rez-test mypackage # run the package's tests
rez-release # build + release_hooks + VCS plugins + tag
Conventions worth following:
- Local first, release when ready.
rez-build --installgoes tolocal_packages_path(typically~/packages), which sits at the front ofpackages_path, so your local package shadows the released one. That is the intended test loop — and also the reason--no-localexists when a local build is masking a released one. - Use
--prefixto install somewhere else, for examplerez-build -i --prefix /path/to/repo.rez-releaseis the managed alternative: it targetsrelease_packages_path, runsrelease_hooks, runs tests, does sanity checks and VCS tagging, and adds the release package attributes automatically. - Inspect before you build.
rez-build --view-preprints the preprocessed package and exits — the fastest way to see whatpreprocess()and@early()actually produced. - Build one variant while iterating:
rez-build --variants 0. - Pass build-system args after
--:rez-build -- -DMYVAR=YES. - Do not re-resolve a test shell unnecessarily — if requirements have not changed, the existing environment is still valid.
Pitfalls and anti-patterns
| Anti-pattern | Why it breaks | Do instead |
|---|---|---|
foo==1.2.3 on every dependency |
over-constrains every downstream resolve | range it: foo-1.2+<2, pin only real incompatibilities |
No variants on a package that will need them |
variants cannot be added later without a version bump | declare one variant now |
| Non-mutually-exclusive variants, relying on the pick | rez cannot compare versions across packages; the choice is a preference, and a transitive dep can overrule it | put the discriminator in the request, e.g. rez-env pkg maya |
Heavy work in @late() |
runs at resolve/runtime, on every use | compute at build time in @early(), keep @late() for genuine runtime state |
Imports at the top of package.py for @late() |
late functions must import inside the function | import inside the function body |
Referencing other @early()/@late() attributes from an @early() function |
raises an error | reference plain attributes only, via this |
Expecting commands() to run during your own build |
it never runs for the package being built | use pre_build_commands() |
Referencing files by relative path in commands() |
cwd during a build is the build path, not the package root | use {root} |
| Editing a released package in place | breaks timestamped resolves and returns stale cached resolves | release a new version |
| Setting env vars for build consumers unconditionally | pollutes runtime | guard with if building: |
Skipping tools and appending to PATH by hand |
loses tool tracking and conflict detection | use the tools attribute |
For the ones that produce a rez error rather than a bad decision — top-level imports that make the
installed package.py unparseable, values frozen on the build machine — see
rez-package-pitfalls, which carries the verbatim error text.
Review checklist
Before releasing, check in this order:
- Does every range say what the package actually supports — not wider, not narrower?
- Does the package need variants now, so it can gain more later without a version bump?
- Are build-only dependencies in
build_requires/private_build_requires, and is the transitive one the right choice? - Does every
@early()function return the right value whenbuildingisFalse? - Is build information in
commands()guarded byif building:? - Does
rez-build --view-preshow the package you intended? - Did
rez-testpass beforerez-release?
Agent workflow
- Read the existing
package.pyandrez-build --view-preoutput before editing anything. - Classify the requested change against decision 1 — version or variant — before touching
requires. - Widen or narrow each range deliberately; never copy a range from another package without checking it means the same thing.
- Prefer
~and!over hard requirements when you are constraining rather than requiring. - Validate with
rez-build --view-pre, thenrez-build --install, thenrez-envin a separate shell — cheapest check first. - Run
rez-testbeforerez-release.
