Fix rex parent_environ case sensitivity on Windows#2098
Conversation
|
|
|
Closing+reopening to retrigger Read the Docs (initial build failed during clone, 2s duration, generic clone error — fork branch is public and accessible). |
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 AcademySoftwareFoundation#2089 Reported by @nrusch. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
f3db9fb to
fa436d7
Compare
|
Status note for reviewers:
Local pre-push validation: |
maxnbk
left a comment
There was a problem hiding this comment.
Apologies for taking a long time to get to this review.
I've commented on a few specific items.
More generally, _CaseInsensitiveEnvironProxy could have a .copy() method added, which would be defensive against future code calling self.parent_environ.copy() to return a plain dict with uppercased keys.
ActionManager.__init__ docstring could document the Windows wrapping behavior.
And last, I feel it would be useful to consider a non-Windows identity passthrough test. i.e. assert ex.manager.parent_environ is env on non-Windows to guard against accidental wrapping. With that in mind, the test skip could be avoided and just assert differently for different host types, or be a different test skipping windows hosts, either way.
If you are using an LLM to produce this PR, please add a line to the PR description disclosing the usage, i.e. "Disclosure: used for (whichever things it was used for.)
| """ | ||
| from rez.utils.platform_ import platform_ | ||
| if platform_.name != "windows": | ||
| self.skipTest("Windows-only behaviour") |
There was a problem hiding this comment.
Let's use the @unittest.skipIf decorator instead of the in-test skip, to prevent the test from even firing on non-windows hosts.
There was a problem hiding this comment.
Done in 1c60859 — switched to @unittest.skipIf(platform_.name != "windows", ...) with platform_ imported at module level. I also added test_parent_environ_identity_passthrough_on_non_windows (asserts parent_environ is env on non-Windows) and a platform-independent test_case_insensitive_environ_proxy, so the core normalization logic is exercised on Linux/macOS CI rather than skipped everywhere but Windows.
| return self._data[key.upper()] | ||
|
|
||
| def __contains__(self, key): | ||
| return isinstance(key, str) and key.upper() in self._data |
There was a problem hiding this comment.
I don't believe the isinstance(key, str) check is necessary even if harmless. All callers use string keys. The guard means that non_string_key in proxy returns False instead of raising TypeError (which is what a normal dict does).
There was a problem hiding this comment.
Done in 1c60859 — dropped the guard; __contains__ is now just return key.upper() in self._data. Agreed the type contract is Mapping[str, str], so non-str keys are out of contract anyway.
549c434 to
1b6f0ce
Compare
- 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.<MixedCase> entrypoint via _test (execute_function + execute_code). Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
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 AcademySoftwareFoundation#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>
1b6f0ce to
c0b42f8
Compare
On Windows, environment variable keys are case-insensitive natively.
os.environpreserves that contract through Python'sos._Environwrapper, but a plaindictcopy of it (or any user-built dict) does not. Until now,ActionManagerconsumed whateverMappingthe caller passed as-is, so a package whosecommands()referencedenv.SomeVariableagainst aparent_environthat held the same variable under a different casing would raise:Issue #2089 has a clean reproducer that follows exactly this shape:
dict(os.environ)is passed toResolvedContext.get_environ, and a package looks upenv.SomeVariablewhile the dict only hasSOMEVARIABLE.The fix wraps a caller-supplied
parent_environin a small read-only, case-normalized snapshot (_CaseInsensitiveEnvironProxy), gated onplatform_.name == "windows".os.environandNoneare used as-is (they are already case-insensitive on Windows), so their identity and liveness are preserved. On Linux and macOS a caller-supplied mapping retains the existing identity passthrough. Keys are upper-cased once at construction, i.e. a point-in-time snapshot, which matches howparent_environis fixed for an executor's lifetime; iteration matches whatos.environyields on Windows, and when the input contains keys that differ only by case the last one encountered wins, mirroring howos.environcollapses duplicates. The proxy also exposes.copy()(returning a plain dict with upper-cased keys) for callers that expect a dict-likeparent_environ. Thenot isinstance(...)guard in the gate keeps wrapping idempotent.Tests:
test_case_insensitive_environ_proxyunit-tests the proxy directly and runs on every platform, so the core normalization logic has real CI coverage: get/contains across casings, missing-keyKeyError,.copy(), iteration/length, and last-write-wins collisions.test_parent_environ_case_insensitive_on_windows(Windows-only via@unittest.skipIf) drives the real package-commands entrypoint —env.<MixedCase>— through bothexecute_functionandexecute_codevia the_testhelper, alongside directparent_environ[...]lookups across casings.test_parent_environ_identity_passthrough_on_non_windows(non-Windows) assertsex.manager.parent_environ is envto guard against accidental wrapping.Verified locally on Linux:
python -m unittest rez.tests.test_rex→ 17 passed + 1 Windows-only skip. The fullrez-selftest,flake8, andmypymatrix runs in CI.Scope: this makes lookups against a caller-supplied
parent_environcase-insensitive on Windows — the exact #2089 reproducer. Variables set within rex code (ActionManager.environ) are stored verbatim and are not case-folded; that is a separate, pre-existing gap and intentionally out of scope here — tracked in #2164.Closes #2089
Reported by @nrusch. Independent diagnosis previously posted in #2093 (closed because the author could not satisfy the EasyCLA/DCO requirement, not for technical reasons).
Disclosure: Claude Code (Opus 4.8) used for adversarial review, edge-case analysis, test design, and implementation of the review feedback.