diff --git a/docs/source/package_commands.rst b/docs/source/package_commands.rst index 731f207cd5..b0bc2af24f 100644 --- a/docs/source/package_commands.rst +++ b/docs/source/package_commands.rst @@ -205,6 +205,12 @@ what environment variables are actually paths. You determine this with the variable ending in ``PATH`` will be treated as a filepath or list of filepaths, and any set/append/prepend operation on it will cause those values to be path-normalized automatically. +However, some variables match ``*PATH`` but must not be path-normalized. For example, +``CMAKE_MODULE_PATH`` requires forward slashes regardless of shell, and backslash conversion will +break CMake. You can exclude variables from normalization using the :data:`non_pathed_env_vars` +config setting, which takes priority over :data:`pathed_env_vars`. By default, +``CMAKE_MODULE_PATH`` is already excluded. Both settings support :func:`fnmatch`-style wildcards. + .. warning:: Avoid using :data:`os.pathsep` or hardcoded lists of paths such as ``{root}/foo:{root}/bah``. Doing so can cause your package to be incompatible with some shells or diff --git a/src/rez/config.py b/src/rez/config.py index d69895825a..ee2d1b21d5 100644 --- a/src/rez/config.py +++ b/src/rez/config.py @@ -389,6 +389,7 @@ def _parse_env_var(self, value): "release_hooks": StrList, "context_tracking_context_fields": StrList, "pathed_env_vars": StrList, + "non_pathed_env_vars": StrList, "prompt_release_message": Bool, "critical_styles": OptionalStrList, "error_styles": OptionalStrList, diff --git a/src/rez/rex.py b/src/rez/rex.py index da26ef5185..b9b86c1379 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -569,6 +569,9 @@ def escape_string(self, value: str | EscapedString, is_path: bool = False) -> st @classmethod def _is_pathed_key(cls, key): + """Return True if ``key`` is a path-like env var subject to normalization.""" + if any(fnmatch(key, x) for x in config.non_pathed_env_vars): + return False return any(fnmatch(key, x) for x in config.pathed_env_vars) def normalize_path(self, path): diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py index d596a993a2..8b3960932f 100644 --- a/src/rez/rezconfig.py +++ b/src/rez/rezconfig.py @@ -539,6 +539,15 @@ "*PATH" ] +# This setting identifies environment variables that should not have path +# normalization applied, even if they match a pattern in :data:`pathed_env_vars`. +# This is useful for variables like ``CMAKE_MODULE_PATH`` which end in ``PATH`` +# but require forward slashes regardless of the shell. Wildcards are supported. +# Takes priority over :data:`pathed_env_vars`. +non_pathed_env_vars = [ + "CMAKE_*_PATH" +] + # Defines what suites on ``$PATH`` stay visible when a new rez environment is resolved. # Possible values are: # diff --git a/src/rez/tests/test_rex.py b/src/rez/tests/test_rex.py index ac3a8966df..23818afadd 100644 --- a/src/rez/tests/test_rex.py +++ b/src/rez/tests/test_rex.py @@ -5,9 +5,9 @@ """ test the rex command generator API """ -from rez.rex import RexExecutor, Python, Setenv, Appendenv, Prependenv, Info, \ - Comment, Alias, Command, Source, Error, Shebang, Unsetenv, expandable, \ - literal +from rez.rex import RexExecutor, Python, ActionInterpreter, Setenv, Appendenv, \ + Prependenv, Info, Comment, Alias, Command, Source, Error, Shebang, Unsetenv, \ + expandable, literal from rez.rex_bindings import VersionBinding, VariantBinding, VariantsBinding, \ RequirementsBinding, EphemeralsBinding, intersects from rez.exceptions import RexError, RexUndefinedVariableError @@ -547,6 +547,33 @@ def test_intersects_ephemerals(self) -> None: self.assertRaises(RuntimeError, # no default intersects, ephemerals.get_range("foo.bar"), "0") + def test_is_pathed_key(self): + """Test that _is_pathed_key correctly identifies path-like env vars.""" + self.assertTrue(ActionInterpreter._is_pathed_key("PATH")) + self.assertTrue(ActionInterpreter._is_pathed_key("PYTHONPATH")) + self.assertTrue(ActionInterpreter._is_pathed_key("LD_LIBRARY_PATH")) + self.assertTrue(ActionInterpreter._is_pathed_key("SOMEPATH")) + + self.assertFalse(ActionInterpreter._is_pathed_key("FOO")) + self.assertFalse(ActionInterpreter._is_pathed_key("HOME")) + + def test_non_pathed_env_vars(self): + """Test that non_pathed_env_vars excludes vars from path normalization.""" + self.assertFalse(ActionInterpreter._is_pathed_key("CMAKE_MODULE_PATH")) + + self.assertTrue(ActionInterpreter._is_pathed_key("PYTHONPATH")) + self.assertTrue(ActionInterpreter._is_pathed_key("PATH")) + + config.override("non_pathed_env_vars", []) + self.assertTrue(ActionInterpreter._is_pathed_key("CMAKE_MODULE_PATH")) + + def test_non_pathed_env_vars_wildcard_patterns(self): + """Test that wildcard patterns work in non_pathed_env_vars.""" + config.override("non_pathed_env_vars", ["*CMAKE*"]) + self.assertFalse(ActionInterpreter._is_pathed_key("CMAKE_MODULE_PATH")) + self.assertFalse(ActionInterpreter._is_pathed_key("CMAKE_PREFIX_PATH")) + self.assertTrue(ActionInterpreter._is_pathed_key("PYTHONPATH")) # not excluded + if __name__ == '__main__': unittest.main() diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index ee2fa3404e..68db5c4297 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -729,6 +729,35 @@ def test_zsh_zshenv(self): "Expected rez wrapper .zshenv in ZDOTDIR=%s" % zdotdir, ) + @unittest.skipIf(platform_.name != "windows", "cmd shell path normalization only relevant on Windows") + def test_cmd_non_pathed_env_var_not_normalized(self): + """Test that CMAKE_MODULE_PATH values are not backslash-converted in cmd shell output. + + CMAKE_MODULE_PATH ends with PATH so it matches the default pathed_env_vars + pattern, but it is excluded by default via non_pathed_env_vars. CMake + requires forward slashes and breaks if backslashes are used. + """ + sh = create_shell("cmd") + sh.setenv("CMAKE_MODULE_PATH", "C:/foo/bar") + output = sh.get_output() + + self.assertIn("C:/foo/bar", output) + self.assertNotIn("C:\\foo\\bar", output) + + @unittest.skipIf(platform_.name != "windows", "cmd shell path normalization only relevant on Windows") + def test_cmd_pathed_env_var_is_normalized(self): + """Test that a normal *PATH variable still gets backslash-converted in cmd shell output. + + Ensures that non_pathed_env_vars exclusion does not accidentally suppress + normalization for legitimate path variables. + """ + sh = create_shell("cmd") + sh.setenv("SOME_CUSTOM_PATH", "C:/foo/bar") + output = sh.get_output() + + self.assertIn("C:\\foo\\bar", output) + self.assertNotIn("C:/foo/bar", output) + if __name__ == '__main__': unittest.main() diff --git a/src/rezplugins/build_system/cmake_files/InstallPython.cmake b/src/rezplugins/build_system/cmake_files/InstallPython.cmake index 422ff3944c..8d3df4b38a 100644 --- a/src/rezplugins/build_system/cmake_files/InstallPython.cmake +++ b/src/rezplugins/build_system/cmake_files/InstallPython.cmake @@ -107,8 +107,9 @@ macro (install_python) add_custom_command( OUTPUT ${local_fc} COMMAND ${CMAKE_COMMAND} -E make_directory ${pycopy_path} - COMMAND ${py_bin} -c 'import py_compile \; py_compile.compile(\"${fabs}\", \"${local_fc}\", None, True)' + COMMAND ${py_bin} -c "import sys; import py_compile; py_compile.compile(sys.argv[1], sys.argv[2], None, True)" ${fabs} ${local_fc} DEPENDS ${fabs} + VERBATIM ) if(install_pyc)