Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name = "late_binding_invalid"

version = "1.0"


@late()
def requires():
# Returns a non-None value that will fail schema validation when a strict
# schema is applied. Used to test the re-raise path in _wrap_forwarded.
return 42


def commands() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name = "late_binding_none"

version = "1.0"


@late()
def requires():
pass


@late()
def build_requires():
pass


@late()
def private_build_requires():
pass


def commands() -> None:
pass
9 changes: 6 additions & 3 deletions src/rez/package_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ def late_bound(schema):


# used when 'requires' is late bound
late_requires_schema = Schema([
Or(PackageRequest, And(str, Use(PackageRequest)))
])
late_requires_schema = Schema(
Or(
And(None, Use(lambda _: [])),
[Or(PackageRequest, And(str, Use(PackageRequest)))]
)
)


# ------------------------------------------------------------------------------
Expand Down
17 changes: 16 additions & 1 deletion src/rez/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from rez.utils.schema import schema_keys
from rez.utils.resources import ResourceHandle, ResourceWrapper
from rez.exceptions import PackageFamilyNotFoundError, ResourceError
from rez.utils.logging_ import print_warning
from rez.utils.typing import SupportsWrite
from rez.version import Version, VersionRange
from rez.version import VersionedObject
Expand Down Expand Up @@ -168,7 +169,21 @@ def _wrap_forwarded(self, key: str, value: Any) -> Any:

schema = self.late_bind_schemas.get(key)
if schema is not None:
value_ = schema.validate(value_)
try:
value_ = schema.validate(value_)
except Exception:
if value_ is None:
Comment on lines +172 to +175
# A late-bound function returned None. This is
# equivalent to the attribute not being set, so
# warn and treat it as a no-op rather than crashing.
# See https://github.com/AcademySoftwareFoundation/rez/issues/2153
print_warning(
"Late-bound attribute %r on package %r returned"
" None; treating as unset.", key, self.name
)
value_ = None
else:
raise

# cache result of late bound func
self._late_binding_returnvalues[key] = value_
Expand Down
50 changes: 50 additions & 0 deletions src/rez/tests/test_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
from rez.tests.util import TestBase, TempdirMixin
from rez.utils.formatting import PackageRequest
from rez.utils.sourcecode import SourceCode
from rez.vendor.schema.schema import Schema, SchemaError
import unittest
from unittest.mock import patch
from rez.version import Version
from rez.version import VersionError
from rez.utils.filesystem import canonical_path
import logging
import shutil
import os.path
import os
Expand Down Expand Up @@ -55,6 +58,8 @@
'single_unversioned',
'single_versioned-3.5',
'late_binding-1.0',
'late_binding_none-1.0',
'late_binding_invalid-1.0',
'timestamped-1.0.5', 'timestamped-1.0.6', 'timestamped-1.1.0', 'timestamped-1.1.1',
'timestamped-1.2.0', 'timestamped-2.0.0', 'timestamped-2.1.0', 'timestamped-2.1.5',
'multi-1.0', 'multi-1.1', 'multi-1.2', 'multi-2.0',
Expand Down Expand Up @@ -158,6 +163,14 @@ def test_pkg_data(self) -> None:
package = get_package("late_binding", "1.0")
self.assertEqual(package.tools, ["util"])

# a py-based package where late binding functions return None.
# This should be normalised to empty lists, not crash.
# See https://github.com/AcademySoftwareFoundation/rez/issues/2153
package = get_package("late_binding_none", "1.0")
self.assertEqual(package.requires, [])
self.assertEqual(package.build_requires, [])
self.assertEqual(package.private_build_requires, [])

# a 'combined' type package
package = get_package("multi", "1.0")
expected_uri = canonical_path(os.path.join(self.yaml_packages_path, "multi.yaml<1.0>"))
Expand Down Expand Up @@ -185,6 +198,43 @@ def test_pkg_data(self) -> None:
expected_uri = canonical_path(os.path.join(self.py_packages_path, "multi.py<2.0>"))
self.assertEqual(package.uri, expected_uri)

def test_late_binding_none_strict_schema(self) -> None:
"""Test that a late-bound attribute returning None under a strict schema
warns and is treated as unset rather than crashing.

See https://github.com/AcademySoftwareFoundation/rez/issues/2153
"""
package = get_package("late_binding_none", "1.0")

# The built-in late_requires_schema already accepts None. Patch it
# with a strict schema that rejects None to exercise the defensive
# fallback path in _wrap_forwarded.
strict_schema = Schema([str])
strict_schemas = {
"requires": strict_schema,
"build_requires": strict_schema,
"private_build_requires": strict_schema,
}

with patch.object(type(package), "late_bind_schemas", strict_schemas):
with self.assertLogs(
logger=logging.getLogger("rez.utils.logging_"),
level=logging.WARNING,
) as cm:
result = package.requires
self.assertIsNone(result)
self.assertTrue(
any("returned None" in msg for msg in cm.output),
f"Expected warning about None return, got: {cm.output}",
)

# A late-bound function returning a non-None invalid value should
# still raise — the defensive fallback only applies to None.
invalid_package = get_package("late_binding_invalid", "1.0")
with patch.object(type(invalid_package), "late_bind_schemas", strict_schemas):
with self.assertRaises(SchemaError):
invalid_package.requires

def test_pkg_create(self) -> None:
"""test package creation."""
package_data = {
Expand Down
Loading