Skip to content

Fix rex parent_environ case sensitivity on Windows#2098

Open
thc1006 wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
thc1006:fix/rex-windows-parent-environ-case-2089
Open

Fix rex parent_environ case sensitivity on Windows#2098
thc1006 wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
thc1006:fix/rex-windows-parent-environ-case-2089

Conversation

@thc1006

@thc1006 thc1006 commented May 7, 2026

Copy link
Copy Markdown

On Windows, environment variable keys are case-insensitive natively. os.environ preserves that contract through Python's os._Environ wrapper, 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 whose commands() referenced env.SomeVariable against a parent_environ that held the same variable under a different casing would raise:

Error in commands in package '...':
Referenced undefined environment variable: SomeVariable

Issue #2089 has a clean reproducer that follows exactly this shape: dict(os.environ) is passed to ResolvedContext.get_environ, and a package looks up env.SomeVariable while the dict only has SOMEVARIABLE.

The fix wraps a caller-supplied parent_environ in a small read-only, case-normalized snapshot (_CaseInsensitiveEnvironProxy), gated on platform_.name == "windows". os.environ and None are 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 how parent_environ is fixed for an executor's lifetime; iteration matches what os.environ yields on Windows, and when the input contains keys that differ only by case the last one encountered wins, mirroring how os.environ collapses duplicates. The proxy also exposes .copy() (returning a plain dict with upper-cased keys) for callers that expect a dict-like parent_environ. The not isinstance(...) guard in the gate keeps wrapping idempotent.

Tests:

  • test_case_insensitive_environ_proxy unit-tests the proxy directly and runs on every platform, so the core normalization logic has real CI coverage: get/contains across casings, missing-key KeyError, .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 both execute_function and execute_code via the _test helper, alongside direct parent_environ[...] lookups across casings.
  • test_parent_environ_identity_passthrough_on_non_windows (non-Windows) asserts ex.manager.parent_environ is env to guard against accidental wrapping.

Verified locally on Linux: python -m unittest rez.tests.test_rex → 17 passed + 1 Windows-only skip. The full rez-selftest, flake8, and mypy matrix runs in CI.

Scope: this makes lookups against a caller-supplied parent_environ case-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.

@thc1006
thc1006 requested a review from a team as a code owner May 7, 2026 05:12
@linux-foundation-easycla

linux-foundation-easycla Bot commented May 7, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: thc1006 / name: thc1006 (f3db9fb)

@thc1006

thc1006 commented May 7, 2026

Copy link
Copy Markdown
Author

Closing+reopening to retrigger Read the Docs (initial build failed during clone, 2s duration, generic clone error — fork branch is public and accessible).

@thc1006 thc1006 closed this May 7, 2026
@thc1006 thc1006 reopened this May 7, 2026
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>
@thc1006
thc1006 force-pushed the fix/rex-windows-parent-environ-case-2089 branch from f3db9fb to fa436d7 Compare May 7, 2026 05:21
@thc1006

thc1006 commented May 7, 2026

Copy link
Copy Markdown
Author

Status note for reviewers:

  • The six core GitHub Actions workflows (tests, flake8, mypy, copyright, installation, benchmark) are queued in action_required state, awaiting maintainer approval per the first-time-contributor policy. Nothing to do on my side.
  • The Read the Docs failure is git fetch origin pull/2098/head returning fatal: couldn't find remote ref pull/2098/head. The GitHub git API for this PR currently exposes only refs/pull/2098/merge; refs/pull/2098/head is missing from matching-refs/pull/2098, while other open PRs (e.g. Allow building with cmake on Windows #2090, Fix shell auto-completion #2091) have both refs as expected. An amend force-push was attempted to nudge GitHub into recreating the head ref but it has not reappeared. This appears to be a GitHub-side propagation glitch independent of the diff. Approving the workflows may incidentally fix it; if not, an RTD admin rebuild should clear it.
  • DCO and EasyCLA are both green.

Local pre-push validation: pytest src/rez/tests/test_rex.py is green (15 pass + 1 Windows-only skip); full rez-selftest on a hermetic Python 3.11.14 container reports Ran 259 tests in 24.997s, OK (skipped=10); flake8 src/rez/rex.py src/rez/tests/test_rex.py is clean; mypy | mypy-baseline filter --ignore-categories=note --allow-unsynced reports no new violations above the pinned baseline.

Copilot AI review requested due to automatic review settings May 7, 2026 06:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@maxnbk maxnbk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Comment thread src/rez/tests/test_rex.py Outdated
"""
from rez.utils.platform_ import platform_
if platform_.name != "windows":
self.skipTest("Windows-only behaviour")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the @unittest.skipIf decorator instead of the in-test skip, to prevent the test from even firing on non-windows hosts.

@thc1006 thc1006 Jul 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/rez/rex.py Outdated
return self._data[key.upper()]

def __contains__(self, key):
return isinstance(key, str) and key.upper() in self._data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@thc1006 thc1006 Jul 21, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

thc1006 added 2 commits July 21, 2026 19:17
- 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>
@thc1006
thc1006 force-pushed the fix/rex-windows-parent-environ-case-2089 branch from 1b6f0ce to c0b42f8 Compare July 21, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rex environment lookups do not respect platform case-sensitivity rules

3 participants