From fa436d70012370316130642816a8e0054778305e Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Thu, 7 May 2026 05:21:20 +0000 Subject: [PATCH 1/5] Fix rex parent_environ case sensitivity on Windows On Windows env-var keys are case-insensitive natively. os.environ preserves that contract via os._Environ, but a plain dict copy of it (or any user-built dict) does not. Until now, ActionManager consumed whatever Mapping the caller passed as-is, so a package commands() that referenced env.SomeVariable against a parent_environ holding the same key under a different case raised RexUndefinedVariableError. Wrap the caller-supplied parent_environ in a small read-only case-insensitive proxy on the Windows path only. The proxy is module private; the wrapping is gated on platform_.name == "windows" and is idempotent so re-entering the gate is a no-op. Linux and macOS take the existing identity assignment unchanged. Also adds a Windows-only regression test that mirrors the issue reproducer and asserts both direct lookup and end-to-end rex code paths against a mixed-case parent_environ. Closes #2089 Reported by @nrusch. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- src/rez/rex.py | 45 ++++++++++++++++++++++++++++++++++++--- src/rez/tests/test_rex.py | 33 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/rez/rex.py b/src/rez/rex.py index da26ef5185..1af3919099 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -12,8 +12,8 @@ from enum import Enum from contextlib import contextmanager from string import Formatter -from collections.abc import MutableMapping -from typing import Any, Iterable, Mapping +from collections.abc import Mapping, MutableMapping +from typing import Any, Iterable from rez.system import system from rez.config import config @@ -200,7 +200,17 @@ def __init__(self, interpreter: ActionInterpreter, ''' self.interpreter = interpreter self.verbose = verbose - self.parent_environ = os.environ if parent_environ is None else parent_environ + # On Windows env-var keys are case-insensitive; os.environ already + # behaves that way but a plain dict supplied by the caller does not, + # so wrap it on the Windows path only. See #2089. + self.parent_environ: Mapping[str, str] + if parent_environ is None: + self.parent_environ = os.environ + elif platform_.name == "windows" and not isinstance( + parent_environ, _CaseInsensitiveEnvironProxy): + self.parent_environ = _CaseInsensitiveEnvironProxy(parent_environ) + else: + self.parent_environ = parent_environ self.parent_variables = True if parent_variables is True \ else set(parent_variables or []) self.environ = {} @@ -447,6 +457,35 @@ def _keytoken(self, key): return self.interpreter.get_key_token(key) +class _CaseInsensitiveEnvironProxy(Mapping): + """Read-only case-insensitive view over an env-var mapping. + + Used by `ActionManager` to wrap a caller-supplied `parent_environ` + on Windows so rex lookups match native Windows env-var semantics + regardless of the casing used to populate the input dict. + + Kept private to this module on purpose; promote to a shared util + only when a second caller needs it. + """ + def __init__(self, data): + # keys are upper-cased once on the way in; same shape as + # os.environ on Windows. Last write wins on case collisions, + # which mirrors how os.environ resolves them too. + self._data = {k.upper(): v for k, v in data.items()} + + def __getitem__(self, key): + return self._data[key.upper()] + + def __contains__(self, key): + return isinstance(key, str) and key.upper() in self._data + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + #=============================================================================== # Interpreters #=============================================================================== diff --git a/src/rez/tests/test_rex.py b/src/rez/tests/test_rex.py index ac3a8966df..8fe79c2858 100644 --- a/src/rez/tests/test_rex.py +++ b/src/rez/tests/test_rex.py @@ -547,6 +547,39 @@ def test_intersects_ephemerals(self) -> None: self.assertRaises(RuntimeError, # no default intersects, ephemerals.get_range("foo.bar"), "0") + def test_parent_environ_case_insensitive_on_windows(self): + """Regression for #2089. + + On Windows env vars are case-insensitive; os.environ already + behaves that way, but a plain dict copy of it doesn't. Passing + such a dict as parent_environ used to break env. + lookups inside commands(). We now wrap on the Windows side. + """ + from rez.utils.platform_ import platform_ + if platform_.name != "windows": + self.skipTest("Windows-only behaviour") + + env = {"SomeVariable": "covfefe"} + ex = self._create_executor(env) + + # any casing of the key resolves + self.assertEqual(ex.manager.parent_environ["SomeVariable"], "covfefe") + self.assertEqual(ex.manager.parent_environ["SOMEVARIABLE"], "covfefe") + self.assertEqual(ex.manager.parent_environ["somevariable"], "covfefe") + self.assertIn("SOMEVARIABLE", ex.manager.parent_environ) + self.assertNotIn("OtherVariable", ex.manager.parent_environ) + + # end-to-end: rex code references a different casing than the + # caller's dict and the lookup must succeed (pre-fix this + # raised RexUndefinedVariableError) + def _rex(): + v = getenv("SOMEVARIABLE") # noqa: F821 - rex builtin + setenv("RESULT", v) # noqa: F821 - rex builtin + + ex2 = self._create_executor({"SomeVariable": "covfefe"}) + ex2.execute_function(_rex) + self.assertEqual(ex2.get_output().get("RESULT"), "covfefe") + if __name__ == '__main__': unittest.main() From 1c60859e4734c2cf2c7538c3ed74704aa40ae0da Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:36:21 +0800 Subject: [PATCH 2/5] Address review feedback for parent_environ case-insensitivity - Add _CaseInsensitiveEnvironProxy.copy() returning a plain dict with upper-cased keys, for callers that expect a dict-like parent_environ. - Pass os.environ through unwrapped (it is already case-insensitive on Windows), preserving its identity and liveness. - Document the Windows wrapping behaviour in ActionManager.__init__ and clarify that the proxy is a construction-time snapshot, not a live view. - Drop the isinstance(key, str) guard in __contains__ so the proxy behaves like a normal mapping. - Tests: add a platform-independent unit test for the proxy, a non-Windows identity passthrough test, and switch the Windows regression test to @unittest.skipIf and the real env. entrypoint via _test (execute_function + execute_code). Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- src/rez/rex.py | 24 +++++++++++--- src/rez/tests/test_rex.py | 66 ++++++++++++++++++++++++++++++--------- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/rez/rex.py b/src/rez/rex.py index 1af3919099..66b1dcf186 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -187,7 +187,10 @@ def __init__(self, interpreter: ActionInterpreter, interpreter: string or `ActionInterpreter` the interpreter to use when executing rex actions parent_environ: environment to execute the actions within. If None, - defaults to the current environment. + defaults to the current environment. On Windows, a caller-supplied + mapping is wrapped in a read-only, case-insensitive snapshot so that + env-var lookups match native Windows semantics regardless of key + casing (see #2089); `os.environ` and None are used as-is. parent_variables: List of variables to append/prepend to, rather than overwriting on first reference. If this is set to True instead of a list, all variables are treated as parent variables. @@ -204,7 +207,9 @@ def __init__(self, interpreter: ActionInterpreter, # behaves that way but a plain dict supplied by the caller does not, # so wrap it on the Windows path only. See #2089. self.parent_environ: Mapping[str, str] - if parent_environ is None: + if parent_environ is None or parent_environ is os.environ: + # os.environ is already case-insensitive on Windows, so use it + # as-is (preserving liveness and identity); None means "current". self.parent_environ = os.environ elif platform_.name == "windows" and not isinstance( parent_environ, _CaseInsensitiveEnvironProxy): @@ -458,12 +463,17 @@ def _keytoken(self, key): class _CaseInsensitiveEnvironProxy(Mapping): - """Read-only case-insensitive view over an env-var mapping. + """Read-only, case-insensitive snapshot of an env-var mapping. Used by `ActionManager` to wrap a caller-supplied `parent_environ` on Windows so rex lookups match native Windows env-var semantics regardless of the casing used to populate the input dict. + Keys are normalized (upper-cased) once at construction, so this is a + point-in-time snapshot: later mutations to the source mapping are not + reflected. That matches how `parent_environ` is used in practice (fixed + for the lifetime of an executor). + Kept private to this module on purpose; promote to a shared util only when a second caller needs it. """ @@ -477,7 +487,7 @@ def __getitem__(self, key): return self._data[key.upper()] def __contains__(self, key): - return isinstance(key, str) and key.upper() in self._data + return key.upper() in self._data def __iter__(self): return iter(self._data) @@ -485,6 +495,12 @@ def __iter__(self): def __len__(self): return len(self._data) + def copy(self): + # Return a plain dict with normalized (upper-cased) keys, mirroring + # os.environ.copy() on Windows. Defensive: lets callers that expect a + # dict-like parent_environ (e.g. `self.parent_environ.copy()`) work. + return dict(self._data) + #=============================================================================== # Interpreters diff --git a/src/rez/tests/test_rex.py b/src/rez/tests/test_rex.py index 8fe79c2858..63bc0fcf5e 100644 --- a/src/rez/tests/test_rex.py +++ b/src/rez/tests/test_rex.py @@ -17,6 +17,7 @@ from rez.version import Requirement from rez.tests.util import TestBase from rez.utils.backcompat import convert_old_commands +from rez.utils.platform_ import platform_ from rez.package_repository import package_repository_manager from rez.packages import iter_package_families import inspect @@ -547,7 +548,45 @@ def test_intersects_ephemerals(self) -> None: self.assertRaises(RuntimeError, # no default intersects, ephemerals.get_range("foo.bar"), "0") - def test_parent_environ_case_insensitive_on_windows(self): + def test_case_insensitive_environ_proxy(self) -> None: + """Unit-test the case-normalizing proxy directly (platform independent). + + Runs on every host so the core logic behind the #2089 fix has real + CI coverage even though the ActionManager wiring is Windows-only. + """ + from rez.rex import _CaseInsensitiveEnvironProxy + + proxy = _CaseInsensitiveEnvironProxy({"SomeVariable": "covfefe"}) + for key in ("SomeVariable", "SOMEVARIABLE", "somevariable"): + self.assertEqual(proxy[key], "covfefe") + self.assertIn(key, proxy) + self.assertNotIn("OtherVariable", proxy) + self.assertRaises(KeyError, lambda: proxy["OtherVariable"]) + + # copy() yields a plain dict with normalized (upper-cased) keys, + # mirroring os.environ.copy() on Windows. + copied = proxy.copy() + self.assertIsInstance(copied, dict) + self.assertEqual(copied, {"SOMEVARIABLE": "covfefe"}) + + # normalized iteration / length + self.assertEqual(len(proxy), 1) + self.assertEqual(set(proxy), {"SOMEVARIABLE"}) + + # last-write-wins on case collision, mirroring os.environ + collided = _CaseInsensitiveEnvironProxy({"Foo": "a", "FOO": "b"}) + self.assertEqual(collided["foo"], "b") + + @unittest.skipIf(platform_.name == "windows", + "parent_environ passthrough is non-Windows behaviour") + def test_parent_environ_identity_passthrough_on_non_windows(self) -> None: + """On non-Windows, a caller dict is used as-is (no wrapping). #2089.""" + env = {"SomeVariable": "covfefe"} + ex = self._create_executor(env) + self.assertIs(ex.manager.parent_environ, env) + + @unittest.skipIf(platform_.name != "windows", "Windows-only behaviour") + def test_parent_environ_case_insensitive_on_windows(self) -> None: """Regression for #2089. On Windows env vars are case-insensitive; os.environ already @@ -555,10 +594,6 @@ def test_parent_environ_case_insensitive_on_windows(self): such a dict as parent_environ used to break env. lookups inside commands(). We now wrap on the Windows side. """ - from rez.utils.platform_ import platform_ - if platform_.name != "windows": - self.skipTest("Windows-only behaviour") - env = {"SomeVariable": "covfefe"} ex = self._create_executor(env) @@ -569,16 +604,19 @@ def test_parent_environ_case_insensitive_on_windows(self): self.assertIn("SOMEVARIABLE", ex.manager.parent_environ) self.assertNotIn("OtherVariable", ex.manager.parent_environ) - # end-to-end: rex code references a different casing than the - # caller's dict and the lookup must succeed (pre-fix this - # raised RexUndefinedVariableError) - def _rex(): - v = getenv("SOMEVARIABLE") # noqa: F821 - rex builtin - setenv("RESULT", v) # noqa: F821 - rex builtin + # end-to-end via the real package-commands entrypoint: rex references + # a different casing than the caller's dict, exercised through both + # execute_function and execute_code (what commands() actually run). + # Pre-fix this raised RexUndefinedVariableError. + def _rex() -> None: + env.RESULT = env.SOMEVARIABLE # noqa: F821 - rex builtin - ex2 = self._create_executor({"SomeVariable": "covfefe"}) - ex2.execute_function(_rex) - self.assertEqual(ex2.get_output().get("RESULT"), "covfefe") + self._test( + func=_rex, + env={"SomeVariable": "covfefe"}, + expected_actions=[Setenv("RESULT", "covfefe")], + expected_output={"RESULT": "covfefe"}, + ) if __name__ == '__main__': From c0b42f893bb0cbd187273d09a72a0fe4ad48b349 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:03:40 +0800 Subject: [PATCH 3/5] Scope the case-insensitivity wording to parent_environ The Windows fix normalizes lookups against a caller-supplied parent_environ only. ActionManager.environ (variables set within rex code) is stored verbatim and is not case-folded, which is a separate, pre-existing concern now tracked in #2164. Tighten the __init__ and proxy docstrings to say "lookups against parent_environ are case-insensitive" instead of the broader "match native Windows semantics", and note the boundary next to self.environ. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- src/rez/rex.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/rez/rex.py b/src/rez/rex.py index 66b1dcf186..42e8bfdb1f 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -189,8 +189,8 @@ def __init__(self, interpreter: ActionInterpreter, parent_environ: environment to execute the actions within. If None, defaults to the current environment. On Windows, a caller-supplied mapping is wrapped in a read-only, case-insensitive snapshot so that - env-var lookups match native Windows semantics regardless of key - casing (see #2089); `os.environ` and None are used as-is. + lookups against it are case-insensitive, matching how ``os.environ`` + behaves natively (see #2089); `os.environ` and None are used as-is. parent_variables: List of variables to append/prepend to, rather than overwriting on first reference. If this is set to True instead of a list, all variables are treated as parent variables. @@ -218,6 +218,9 @@ def __init__(self, interpreter: ActionInterpreter, self.parent_environ = parent_environ self.parent_variables = True if parent_variables is True \ else set(parent_variables or []) + # Variables set during rex execution. Keys are stored verbatim and are + # not case-folded on Windows (unlike the parent_environ snapshot above); + # that broader normalization is a separate concern, tracked in #2164. self.environ = {} self.formatter = formatter or str self.actions = [] @@ -466,8 +469,9 @@ class _CaseInsensitiveEnvironProxy(Mapping): """Read-only, case-insensitive snapshot of an env-var mapping. Used by `ActionManager` to wrap a caller-supplied `parent_environ` - on Windows so rex lookups match native Windows env-var semantics - regardless of the casing used to populate the input dict. + on Windows so lookups against it are case-insensitive regardless of + the casing used to populate the input dict, matching how os.environ + behaves natively on Windows. Keys are normalized (upper-cased) once at construction, so this is a point-in-time snapshot: later mutations to the source mapping are not From 1c3299ea702ef1b8148209582dce89bba5422e24 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:13:31 +0800 Subject: [PATCH 4/5] Fix env-shadowing in Windows test; add passthrough + mocked coverage The Windows regression test declared a local `env` dict, so the nested rex function closed over it (LOAD_DEREF) and shadowed the rex `env` builtin that execute_function injects into globals -- `env.SOMEVARIABLE` then hit a plain dict and failed on Windows CI. Rename the caller dict to `parent_env` in both new tests so the closure no longer shadows `env` (thanks @maxnbk for the diagnosis). Also address the Windows-only coverage gap codecov flagged: - test_parent_environ_os_environ_passthrough: covers the `parent_environ is os.environ` identity branch. - test_parent_environ_case_insensitive_windows_mocked: mocks platform_.name so the case-insensitive wrapping branch (and the full env. end-to-end) is exercised on non-Windows CI too, giving the Windows path real coverage and off-Windows validation. Audited the rest of test_rex.py: no other test declares a local `env`; they all pass `env=` as a kwarg to _test, so none shadow the builtin. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- src/rez/tests/test_rex.py | 49 +++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/rez/tests/test_rex.py b/src/rez/tests/test_rex.py index 63bc0fcf5e..fe27bb9278 100644 --- a/src/rez/tests/test_rex.py +++ b/src/rez/tests/test_rex.py @@ -13,6 +13,7 @@ from rez.exceptions import RexError, RexUndefinedVariableError from rez.config import config import unittest +from unittest import mock from rez.version import Version from rez.version import Requirement from rez.tests.util import TestBase @@ -581,9 +582,9 @@ def test_case_insensitive_environ_proxy(self) -> None: "parent_environ passthrough is non-Windows behaviour") def test_parent_environ_identity_passthrough_on_non_windows(self) -> None: """On non-Windows, a caller dict is used as-is (no wrapping). #2089.""" - env = {"SomeVariable": "covfefe"} - ex = self._create_executor(env) - self.assertIs(ex.manager.parent_environ, env) + parent_env = {"SomeVariable": "covfefe"} + ex = self._create_executor(parent_env) + self.assertIs(ex.manager.parent_environ, parent_env) @unittest.skipIf(platform_.name != "windows", "Windows-only behaviour") def test_parent_environ_case_insensitive_on_windows(self) -> None: @@ -594,8 +595,8 @@ def test_parent_environ_case_insensitive_on_windows(self) -> None: such a dict as parent_environ used to break env. lookups inside commands(). We now wrap on the Windows side. """ - env = {"SomeVariable": "covfefe"} - ex = self._create_executor(env) + parent_env = {"SomeVariable": "covfefe"} + ex = self._create_executor(parent_env) # any casing of the key resolves self.assertEqual(ex.manager.parent_environ["SomeVariable"], "covfefe") @@ -607,13 +608,47 @@ def test_parent_environ_case_insensitive_on_windows(self) -> None: # end-to-end via the real package-commands entrypoint: rex references # a different casing than the caller's dict, exercised through both # execute_function and execute_code (what commands() actually run). - # Pre-fix this raised RexUndefinedVariableError. + # Pre-fix this raised RexUndefinedVariableError. Note: the caller dict + # is named `parent_env`, not `env`, so the nested rex function does not + # close over it and shadow the rex `env` builtin. def _rex() -> None: env.RESULT = env.SOMEVARIABLE # noqa: F821 - rex builtin self._test( func=_rex, - env={"SomeVariable": "covfefe"}, + env=parent_env, + expected_actions=[Setenv("RESULT", "covfefe")], + expected_output={"RESULT": "covfefe"}, + ) + + def test_parent_environ_os_environ_passthrough(self) -> None: + """Explicit os.environ is used as-is (identity), never wrapped or + copied -- it is already case-insensitive on Windows. #2089. + """ + ex = self._create_executor(os.environ) + self.assertIs(ex.manager.parent_environ, os.environ) + + @mock.patch.object(platform_, "name", "windows") + def test_parent_environ_case_insensitive_windows_mocked(self) -> None: + """Companion to test_parent_environ_case_insensitive_on_windows that + runs on every host by mocking the platform, so the Windows-only + wrapping branch keeps CI coverage (and local validation) off Windows + too. #2089. + """ + from rez.rex import _CaseInsensitiveEnvironProxy + + parent_env = {"SomeVariable": "covfefe"} + ex = self._create_executor(parent_env) + self.assertIsInstance( + ex.manager.parent_environ, _CaseInsensitiveEnvironProxy) + self.assertEqual(ex.manager.parent_environ["SOMEVARIABLE"], "covfefe") + + def _rex() -> None: + env.RESULT = env.SOMEVARIABLE # noqa: F821 - rex builtin + + self._test( + func=_rex, + env=parent_env, expected_actions=[Setenv("RESULT", "covfefe")], expected_output={"RESULT": "covfefe"}, ) From c84ef86b238f76d555acdfacc283c840bb05210d Mon Sep 17 00:00:00 2001 From: Jean-Christophe Morin <38703886+JeanChristopheMorinPerso@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:20:03 -0400 Subject: [PATCH 5/5] sphinx stuff Co-authored-by: Jean-Christophe Morin <38703886+JeanChristopheMorinPerso@users.noreply.github.com> Signed-off-by: Jean-Christophe Morin <38703886+JeanChristopheMorinPerso@users.noreply.github.com> --- src/rez/rex.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rez/rex.py b/src/rez/rex.py index 42e8bfdb1f..1946753842 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -189,8 +189,8 @@ def __init__(self, interpreter: ActionInterpreter, parent_environ: environment to execute the actions within. If None, defaults to the current environment. On Windows, a caller-supplied mapping is wrapped in a read-only, case-insensitive snapshot so that - lookups against it are case-insensitive, matching how ``os.environ`` - behaves natively (see #2089); `os.environ` and None are used as-is. + lookups against it are case-insensitive, matching how :data:`os.environ` + behaves natively (see #2089); :data:`os.environ` and None are used as-is. parent_variables: List of variables to append/prepend to, rather than overwriting on first reference. If this is set to True instead of a list, all variables are treated as parent variables. @@ -468,14 +468,14 @@ def _keytoken(self, key): class _CaseInsensitiveEnvironProxy(Mapping): """Read-only, case-insensitive snapshot of an env-var mapping. - Used by `ActionManager` to wrap a caller-supplied `parent_environ` + Used by :class:`ActionManager` to wrap a caller-supplied ``parent_environ`` on Windows so lookups against it are case-insensitive regardless of - the casing used to populate the input dict, matching how os.environ + the casing used to populate the input dict, matching how :data:`os.environ` behaves natively on Windows. Keys are normalized (upper-cased) once at construction, so this is a point-in-time snapshot: later mutations to the source mapping are not - reflected. That matches how `parent_environ` is used in practice (fixed + reflected. That matches how ``parent_environ`` is used in practice (fixed for the lifetime of an executor). Kept private to this module on purpose; promote to a shared util