From 5001ea6eb65db1d216570a83ce3326464e547667 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 22 Aug 2019 18:16:15 -0400 Subject: [PATCH 01/17] Fixes #694 and lets cmd and powershell pass all shell tests. This implements ${VAR} and $VAR variables for cmd and Powershell like, as well as their native forms like %VAR% and $Env:VAR. In order to handle the ambiguity of variables in the form of $Env:Literal in Unix and Windows the NamespaceFormatter may take interpreter regex into account that is being supplied by the underlying shell. For command execution on Windows .PY is being added to the PATHEXT and the create_script function is extended to create any form of execution script. This behaviour can be controlled via a rezconfig, but defaults to backwards compatible Unix-only behaviour. --- src/rez/bind/hello_world.py | 19 ++- src/rez/config.py | 8 + src/rez/rex.py | 25 ++- src/rez/rezconfig.py | 23 +++ src/rez/shells.py | 38 ++++- src/rez/tests/test_shells.py | 169 ++++++++++++++---- src/rez/util.py | 128 +++++++++++--- src/rezplugins/shell/cmd.py | 34 +++- src/rezplugins/shell/powershell.py | 114 ++++++++++--- src/rezplugins/shell/pwsh.py | 266 +---------------------------- src/rezplugins/shell/rezconfig | 3 + 11 files changed, 466 insertions(+), 361 deletions(-) diff --git a/src/rez/bind/hello_world.py b/src/rez/bind/hello_world.py index cd59d8fbf..cb100a265 100644 --- a/src/rez/bind/hello_world.py +++ b/src/rez/bind/hello_world.py @@ -10,7 +10,7 @@ from rez.package_maker__ import make_package from rez.vendor.version.version import Version from rez.utils.lint_helper import env -from rez.util import create_executable_script +from rez.util import create_executable_script, ExecutableScriptMode from rez.bind._utils import make_dirs, check_version import os.path @@ -26,10 +26,10 @@ def hello_world_source(): p = OptionParser() p.add_option("-q", dest="quiet", action="store_true", - help="quiet mode") + help="quiet mode") p.add_option("-r", dest="retcode", type="int", default=0, - help="exit with a non-zero return code") - opts,args = p.parse_args() + help="exit with a non-zero return code") + opts, args = p.parse_args() if not opts.quiet: print("Hello Rez World!") @@ -43,7 +43,15 @@ def bind(path, version_range=None, opts=None, parser=None): def make_root(variant, root): binpath = make_dirs(root, "bin") filepath = os.path.join(binpath, "hello_world") - create_executable_script(filepath, hello_world_source) + + # In order for this script to run on each platform we create the + # platform-specific script. This also requires the additional_pathext + # setting of the windows shell plugins to include ".PY" + create_executable_script( + filepath, + hello_world_source, + py_script_mode=ExecutableScriptMode.platform_specific + ) with make_package("hello_world", path, make_root=make_root) as pkg: pkg.version = version @@ -52,7 +60,6 @@ def make_root(variant, root): return pkg.installed_variants - # Copyright 2013-2016 Allan Johns. # # This library is free software: you can redistribute it and/or diff --git a/src/rez/config.py b/src/rez/config.py index 894408139..cff34bc16 100644 --- a/src/rez/config.py +++ b/src/rez/config.py @@ -239,6 +239,13 @@ def schema(cls): return Or(*(x.name for x in RezToolsVisibility)) +class ExecutableScriptMode_(Str): + @cached_class_property + def schema(cls): + from rez.util import ExecutableScriptMode + return Or(*(x.name for x in ExecutableScriptMode)) + + class OptionalStrOrFunction(Setting): schema = Or(None, basestring, callable) @@ -308,6 +315,7 @@ def _parse_env_var(self, value): "documentation_url": Str, "suite_visibility": SuiteVisibility_, "rez_tools_visibility": RezToolsVisibility_, + "create_executable_script_mode": ExecutableScriptMode_, "suite_alias_prefix_char": Char, "package_definition_python_path": OptionalStr, "tmpdir": OptionalStr, diff --git a/src/rez/rex.py b/src/rez/rex.py index a4ed04f9c..a0093c238 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -446,6 +446,17 @@ class ActionInterpreter(object): """ expand_env_vars = False + # RegEx that captures environment variables (generic form). + # Extend/Override to regex formats that can captured environment formats + # in other interpreters like shells if needed + ENV_VAR_REGEX = re.compile( + "|".join([ + "\\${([^\\{\\}]+?)}", # ${ENVVAR} + "\\$([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $ENVVAR + ]) + ) + + def get_output(self, style=OutputStyle.file): """Returns any implementation specific data. @@ -877,12 +888,6 @@ class NamespaceFormatter(Formatter): across shells, and avoids some problems with non-curly-braced variables in some situations. """ - # Note: the regex used here matches more than just posix environment variable - # names, because special shell expansion characters may be present. - ENV_VAR_REF_1 = "\\${([^\\{\\}]+?)}" # ${ENVVAR} - ENV_VAR_REF_2 = "\\$([a-zA-Z_]+[a-zA-Z0-9_]*?)" # $ENVVAR - ENV_VAR_REF = "%s|%s" % (ENV_VAR_REF_1, ENV_VAR_REF_2) - ENV_VAR_REGEX = re.compile(ENV_VAR_REF) def __init__(self, namespace): Formatter.__init__(self) @@ -894,7 +899,11 @@ def escape_envvar(matchobj): value = next((x for x in matchobj.groups() if x is not None)) return "${{%s}}" % value - format_string_ = re.sub(self.ENV_VAR_REGEX, escape_envvar, format_string) + regex = ActionInterpreter.ENV_VAR_REGEX + if "regex" in kwargs: + regex = kwargs["regex"] + + format_string_ = re.sub(regex, escape_envvar, format_string) # for recursive formatting, where a field has a value we want to expand, # add kwargs to namespace, so format_field can use them... @@ -1254,7 +1263,7 @@ def get_output(self, style=OutputStyle.file): return self.manager.get_output(style=style) def expand(self, value): - return self.formatter.format(str(value)) + return self.formatter.format(str(value), regex=self.interpreter.ENV_VAR_REGEX) # Copyright 2013-2016 Allan Johns. diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py index 3e055327d..44af802b3 100644 --- a/src/rez/rezconfig.py +++ b/src/rez/rezconfig.py @@ -619,6 +619,29 @@ suite_alias_prefix_char = "+" +############################################################################### +# Utils +############################################################################### + +# Default option on how to create scripts with util.create_executable_script. +# In order to support both windows and other OS it is recommended to set this +# to 'both'. +# +# Possible modes: +# - requested: +# Requested shebang script only. Usually extension-less. +# - py: +# Create .py script that will allow launching scripts on windows, +# if the shell adds .py to PATHEXT. Make sure to use PEP-397 py.exe +# as default application for .py files. +# - platform_specific: +# Will create py script on windows and requested on other platforms +# - both: +# Creates the requested file and a .py script so that scripts can be +# launched without extension from windows and other systems. +create_executable_script_mode = "requested" + + ############################################################################### # Appearance ############################################################################### diff --git a/src/rez/shells.py b/src/rez/shells.py index 9bae5992a..c43e67c63 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -15,6 +15,7 @@ import os import os.path import pipes +import re basestring = six.string_types[0] @@ -42,7 +43,6 @@ def create_shell(shell=None, **kwargs): class Shell(ActionInterpreter): """Class representing a shell, such as bash or tcsh. """ - schema_dict = { "prompt": basestring} @@ -164,6 +164,29 @@ def spawn_shell(self, context_file, tmpdir, rcfile=None, norc=False, """ raise NotImplementedError + @classmethod + def get_key_token(cls, key, form=0): + """ + Encodes the environment variable into the shell specific form. + Shells might implement multiple forms, but the most common/safest + should be implemented as form 0 or if the form exceeds key_form_count. + + Args: + key: Variable name to encode + form: number of token form + + Returns: + str of encoded token form + """ + raise NotImplementedError + + @classmethod + def key_form_count(cls): + """ + Returns: Number of forms get_key_token supports + """ + raise NotImplementedError + def join(self, command): """ Args: @@ -175,6 +198,7 @@ def join(self, command): """ raise NotImplementedError + class UnixShell(Shell): """ A base class for common *nix shells, such as bash and tcsh. @@ -400,8 +424,16 @@ def comment(self, value): def shebang(self): self._addline("#!%s" % self.executable) - def get_key_token(self, key): - return "${%s}" % key + @classmethod + def get_key_token(cls, key, form=0): + if form == 1: + return "$%s" % key + else: + return "${%s}" % key + + @classmethod + def key_form_count(cls): + return 2 def join(self, command): return shlex_join(command) diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index 1b2c394d7..fff4717f9 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -6,20 +6,19 @@ from rez.system import system from rez.shells import create_shell from rez.resolved_context import ResolvedContext -from rez.rex import RexExecutor, literal, expandable +from rez.rex import literal, expandable +from rez.util import create_executable_script, _get_python_script_files, ExecutableScriptMode import unittest from rez.tests.util import TestBase, TempdirMixin, shell_dependent, \ install_dependent from rez.util import which from rez.bind import hello_world -from rez.utils.platform_ import platform_ from rez.vendor.six import six import subprocess import tempfile import inspect import textwrap import os -import sys def _stdout(proc): @@ -48,15 +47,10 @@ def tearDownClass(cls): @classmethod def _create_context(cls, pkgs): - from rez.config import config return ResolvedContext(pkgs, caching=False) - @shell_dependent(exclude=["cmd"]) + @shell_dependent() def test_no_output(self): - # TODO: issues with binding the 'hello_world' package means it is not - # possible to run this test on Windows. The 'hello_world' executable - # is not registered correctly on Windows so always returned the - # incorrect error code. sh = create_shell() _, _, _, command = sh.startup_capabilities(command=True) if command: @@ -70,27 +64,69 @@ def test_no_output(self): "startup scripts are printing to stdout. Please remove the " "printout and try again.") - @shell_dependent(exclude=["cmd"]) + def test_create_executable_script(self): + script_file = os.path.join(self.root, "script") + py_script_file = os.path.join(self.root, "script.py") + + for platform in ['windows', 'linux']: + + files = _get_python_script_files(script_file, + ExecutableScriptMode.py, + platform) + self.assertListEqual(files, [py_script_file]) + + files = _get_python_script_files(py_script_file, + ExecutableScriptMode.py, + platform) + self.assertListEqual(files, [py_script_file]) + + files = _get_python_script_files(script_file, + ExecutableScriptMode.requested, + platform) + self.assertListEqual(files, [script_file]) + + files = _get_python_script_files(py_script_file, + ExecutableScriptMode.requested, + platform) + self.assertListEqual(files, [py_script_file]) + + files = _get_python_script_files(script_file, + ExecutableScriptMode.both, + platform) + self.assertListEqual(files, [script_file, py_script_file]) + + files = _get_python_script_files(py_script_file, + ExecutableScriptMode.both, + platform) + self.assertListEqual(files, [py_script_file]) + + files = _get_python_script_files(script_file, + ExecutableScriptMode.platform_specific, + platform) + if platform == "windows": + self.assertListEqual(files, [py_script_file]) + else: + self.assertListEqual(files, [script_file]) + + files = _get_python_script_files(py_script_file, + ExecutableScriptMode.platform_specific, + platform) + self.assertListEqual(files, [py_script_file]) + + @shell_dependent() def test_command(self): - # TODO: issues with binding the 'hello_world' package means it is not - # possible to run this test on Windows. The 'hello_world' executable - # is not registered correctly on Windows so always returned the - # incorrect error code. sh = create_shell() _, _, _, command = sh.startup_capabilities(command=True) if command: r = self._create_context(["hello_world"]) - p = r.execute_shell(command="hello_world", + script = "hello_world" + p = r.execute_shell(command=script, stdout=subprocess.PIPE) self.assertEqual(_stdout(p), "Hello Rez World!") - @shell_dependent(exclude=["cmd"]) + @shell_dependent() def test_command_returncode(self): - # TODO: issues with binding the 'hello_world' package means it is not - # possible to run this test on Windows. The 'hello_world' executable - # is not registered correctly on Windows so always returned the - # incorrect error code. sh = create_shell() _, _, _, command = sh.startup_capabilities(command=True) @@ -146,13 +182,19 @@ def test_rcfile(self): self.assertEqual(_stdout(p), "Hello Rez World!") os.remove(path) - @shell_dependent(exclude=["cmd"]) + @shell_dependent() @install_dependent def test_rez_env_output(self): # here we are making sure that running a command via rez-env prints # exactly what we expect. + sh = create_shell() echo_cmd = which("echo") - if not echo_cmd: + + # Certain shells will not find echo. + # TODO: If this exception was created for the following shells then + # it is redundant. Can we clarify which platforms this was meant for? + has_buildin_echo = sh.name() in ['cmd', 'powershell', 'pwsh'] + if not echo_cmd and not has_buildin_echo: print("\nskipping test, 'echo' command not found.") return @@ -190,13 +232,18 @@ def _execute_code(func, expected_output): out, _ = p.communicate() self.assertEqual(p.returncode, 0) - token = '\r\n' if platform_.name == 'windows' else '\n' + + # Powershell and Unix uses \n + sh = create_shell() + token = '\r\n' if sh.name() == 'cmd' else '\n' + output = out.strip().split(token) self.assertEqual(output, expected_output) def _rex_assigning(): - import os - windows = os.name == "nt" + from rez.shells import create_shell, UnixShell + sh = create_shell() + is_powershell = sh.name() in ["powershell", "pwsh"] def _print(value): env.FOO = value @@ -204,7 +251,10 @@ def _print(value): # interpreting parts of our output as commands. This can happen # when we include special characters (&, <, >, ^) in a # variable. - info('"%FOO%"' if windows else '"${FOO}"') + if is_powershell: + info('`"{}`"'.format(sh.get_key_token("FOO"))) + else: + info('"{}"'.format(sh.get_key_token("FOO"))) env.GREET = "hi" env.WHO = "Gary" @@ -226,12 +276,14 @@ def _print(value): _print(literal("hello world")) _print(literal("hello 'world'")) _print(literal('hello "world"')) - _print("hey %WHO%" if windows else "hey $WHO") - _print("hey %WHO%" if windows else "hey ${WHO}") - _print(expandable("%GREET% " if windows else "${GREET} ").e("%WHO%" if windows else "$WHO")) - _print(expandable("%GREET% " if windows else "${GREET} ").l("$WHO")) + + # Generic form of variables + _print("hey $WHO") + _print("hey ${WHO}") + _print(expandable("${GREET} ").e("$WHO")) + _print(expandable("${GREET} ").l("$WHO")) _print(literal("${WHO}")) - _print(literal("${WHO}").e(" %WHO%" if windows else " $WHO")) + _print(literal("${WHO}").e(" $WHO")) # Make sure we are escaping &, <, >, ^ properly. _print('hey & world') @@ -239,6 +291,17 @@ def _print(value): _print('hey < world') _print('hey ^ world') + # Platform dependent form of variables. + # No need to test in unix shells since their for, matches the + # generic form $VAR and ${VAR}. + if not isinstance(sh, UnixShell): + for i in range(sh.key_form_count()): + _print("hey " + sh.get_key_token("WHO", i)) + _print(expandable("${GREET} ").e(sh.get_key_token("WHO", i))) + _print(expandable("${GREET} ").l(sh.get_key_token("WHO", i))) + _print(literal(sh.get_key_token("WHO", i))) + _print(literal(sh.get_key_token("WHO", i)).e(" " + sh.get_key_token("WHO", i))) + expected_output = [ "ello", "ello", @@ -269,6 +332,21 @@ def _print(value): "hey ^ world" ] + # Assertions for other environment variable types + from rez.shells import create_shell, UnixShell + sh = create_shell() + if not isinstance(sh, UnixShell): + from rez.shells import create_shell, UnixShell + sh = create_shell() + for i in range(sh.key_form_count()): + expected_output += [ + "hey Gary", + "hi Gary", + "hi " + sh.get_key_token("WHO", i), + sh.get_key_token("WHO", i), + sh.get_key_token("WHO", i) + " Gary", + ] + # We are wrapping all variable outputs in quotes in order to make sure # our shell isn't interpreting our output as instructions when echoing # it but this means we need to wrap our expected output as well. @@ -277,15 +355,15 @@ def _print(value): _execute_code(_rex_assigning, expected_output) def _rex_appending(): - import os - windows = os.name == "nt" + from rez.shells import create_shell + sh = create_shell() env.FOO.append("hey") - info("%FOO%" if windows else "${FOO}") + info(sh.get_key_token("FOO")) env.FOO.append(literal("$DAVE")) - info("%FOO%" if windows else "${FOO}") + info(sh.get_key_token("FOO")) env.FOO.append("Dave's not here man") - info("%FOO%" if windows else "${FOO}") + info(sh.get_key_token("FOO")) expected_output = [ "hey", @@ -295,6 +373,25 @@ def _rex_appending(): _execute_code(_rex_appending, expected_output) + @shell_dependent() + def test_variable_encoding(self): + """ + Sanity test so we can make use of get_key_token in other tests. + """ + sh = create_shell() + name = sh.name() + if name == "cmd": + self.assertEqual("%HELLO%", sh.get_key_token("HELLO", 0)) + self.assertEqual(1, sh.key_form_count()) + elif name == "powershell" or name == "pwsh": + self.assertEqual("${Env:HELLO}", sh.get_key_token("HELLO", 0)) + self.assertEqual("$Env:HELLO", sh.get_key_token("HELLO", 1)) + self.assertEqual(2, sh.key_form_count()) + else: + self.assertEqual("${HELLO}", sh.get_key_token("HELLO", 0)) + self.assertEqual("$HELLO", sh.get_key_token("HELLO", 1)) + self.assertEqual(2, sh.key_form_count()) + @shell_dependent() def test_rex_code_alias(self): """Ensure PATH changes do not influence the alias command. diff --git a/src/rez/util.py b/src/rez/util.py index bdb50336f..07e09e0b9 100644 --- a/src/rez/util.py +++ b/src/rez/util.py @@ -11,12 +11,36 @@ from rez.exceptions import RezError from rez.utils.yaml import dump_yaml from rez.vendor.progress.bar import Bar +from rez.vendor.enum import Enum from rez.vendor.six import six - DEV_NULL = open(os.devnull, 'w') +class ExecutableScriptMode(Enum): + """ + Which scripts to create with util.create_executable_script. + """ + # Start with 1 to not collide with None checks + + # Requested shebang script only. Usually extension-less. + requested = 1 + + # Create .py script that will allow launching scripts on + # windows without extension, but may require extension on + # other systems. + py = 2 + + # Will create py script on windows and requested on + # other platforms + platform_specific = 3 + + # Creates the requested script and an .py script so that scripts + # can be launched without extension from windows and other + # systems. + both = 4 + + class ProgressBar(Bar): def __init__(self, label, max): from rez.config import config @@ -27,19 +51,31 @@ def __init__(self, label, max): super(Bar, self).__init__(label, max=max, bar_prefix=' [', bar_suffix='] ') -# TODO: use distlib.ScriptMaker -# TODO: or, do the work ourselves to make this cross platform -# FIXME: *nix only -def create_executable_script(filepath, body, program=None): - """Create an executable script. +# TODO: Maybe also allow distlib.ScriptMaker instead of the .py + PATHEXT. +def create_executable_script(filepath, body, program=None, py_script_mode=None): + """ + Create an executable script. In case a py_script_mode has been set to create + a .py script the shell is expected to have the PATHEXT environment + variable to include ".PY" in order to properly launch the command without + the .py extension. Args: filepath (str): File to create. body (str or callable): Contents of the script. If a callable, its code is used as the script body. - program (str): Name of program to launch the script, 'python' if None + program (str): Name of program to launch the script. Default is 'python' + py_script_mode(ExecutableScriptMode): What kind of script to create. + Defaults to rezconfig.create_executable_script_mode. + Returns: + List of filepaths of created scripts. This may differ from the supplied + filepath depending on the py_script_mode + """ + from rez.config import config + from rez.utils.platform_ import platform_ program = program or "python" + py_script_mode = py_script_mode or config.create_executable_script_mode + if callable(body): from rez.utils.sourcecode import SourceCode code = SourceCode(func=body) @@ -48,18 +84,72 @@ def create_executable_script(filepath, body, program=None): if not body.endswith('\n'): body += '\n' - with open(filepath, 'w') as f: - # TODO: make cross platform - f.write("#!/usr/bin/env %s\n" % program) - f.write(body) - - # TODO: Although Windows supports os.chmod you can only set the readonly - # flag. Setting the file readonly breaks the unit tests that expect to - # clean up the files once the test has run. Temporarily we don't bother - # setting the permissions, but this will need to change. - if os.name == "posix": - os.chmod(filepath, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH - | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + # Windows does not support shebang, but it will run with + # default python, or in case of later python versions 'py' that should + # try to use sensible python interpreters depending on the shebang line. + # Compare PEP-397. + # In order to execution to work from windows we need to create a .py + # file and set the PATHEXT to include .py (as done by the shell plugins) + # So depending on the py_script_mode we might need to create more then + # one script + + script_filepaths = [filepath] + if program == "python": + script_filepaths = _get_python_script_files(filepath, py_script_mode, + platform_.name) + + for current_filepath in script_filepaths: + with open(current_filepath, 'w') as f: + # TODO: make cross platform + f.write("#!/usr/bin/env %s\n" % program) + f.write(body) + + # TODO: Although Windows supports os.chmod you can only set the readonly + # flag. Setting the file readonly breaks the unit tests that expect to + # clean up the files once the test has run. Temporarily we don't bother + # setting the permissions, but this will need to change. + if os.name == "posix": + os.chmod(current_filepath, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + return script_filepaths + + +def _get_python_script_files(filepath, py_script_mode, platform): + """ + Evaluates the py_script_mode for the requested filepath on the given + platform. + + Args: + filepath: requested filepath + py_script_mode (ExecutableScriptMode): + platform (str): Platform to evaluate the script files for + + Returns: + + """ + script_filepaths = [] + base_filepath, extension = os.path.splitext(filepath) + has_py_ext = extension == ".py" + is_windows = platform == "windows" + + if py_script_mode == ExecutableScriptMode.requested or \ + py_script_mode == ExecutableScriptMode.both or \ + (py_script_mode == ExecutableScriptMode.py and has_py_ext) or \ + (py_script_mode == ExecutableScriptMode.platform_specific and + not is_windows) or \ + (py_script_mode == ExecutableScriptMode.platform_specific and + is_windows and has_py_ext): + script_filepaths.append(filepath) + + if not has_py_ext and \ + ((py_script_mode == ExecutableScriptMode.both) or + (py_script_mode == ExecutableScriptMode.py) or + (py_script_mode == ExecutableScriptMode.platform_specific and + is_windows)): + script_filepaths.append(base_filepath + ".py") + + return script_filepaths def create_forwarding_script(filepath, module, func_name, *nargs, **kwargs): diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 4134db046..e486a10c9 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -25,6 +25,9 @@ class CMD(Shell): syspaths = None _executable = None _doskey = None + expand_env_vars = True + + _env_var_regex = re.compile("%([A-Za-z0-9_]+)%") # %ENVVAR% # Regex to aid with escaping of Windows-specific special chars: # http://ss64.com/nt/syntax-esc.html @@ -186,6 +189,12 @@ def _create_ex(): executor.interpreter._saferefenv('REZ_ENV_PROMPT') executor.env.REZ_ENV_PROMPT = literal(newprompt) + # Make .py launch within cmd without extension. + if self.settings.additional_pathext: + executor.command('set PATHEXT=%PATHEXT%;{}'.format( + ";".join(self.settings.additional_pathext) + )) + if startup_sequence["command"] is not None: _record_shell(executor, files=startup_sequence["files"]) shell_command = startup_sequence["command"] @@ -251,9 +260,19 @@ def escape_string(self, value): str: The value escaped for Windows. """ - if isinstance(value, EscapedString): - return value.formatted(self._escaper) - return self._escaper(value) + value = EscapedString.promote(value) + value = value.expanduser() + result = '' + + for is_literal, txt in value.strings: + if is_literal: + txt = self._escaper(txt) + # Note that cmd uses ^% while batch files use %% to escape % + txt = self._env_var_regex.sub(r"%%\1%%", txt) + else: + txt = self._escaper(txt) + result += txt + return result def _saferefenv(self, key): pass @@ -301,8 +320,13 @@ def source(self, value): def command(self, value): self._addline(value) - def get_key_token(self, key): - return "%%%s%%" % key + @classmethod + def get_key_token(cls, key, form=0): + return "%{}%".format(key) + + @classmethod + def key_form_count(cls): + return 1 def join(self, command): return " ".join(command) diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 3d7f92f82..7d05448b8 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -1,7 +1,7 @@ """Windows PowerShell 5""" from rez.config import config -from rez.rex import RexExecutor, OutputStyle, EscapedString +from rez.rex import RexExecutor, OutputStyle, EscapedString, literal from rez.shells import Shell from rez.utils.system import popen from rez.utils.platform_ import platform_ @@ -12,28 +12,43 @@ import re -class PowerShell(Shell): +class PowerShellBase(Shell): + """ + Abstract base class for Powershell-like shells. + """ + expand_env_vars = True + syspaths = None _executable = None - # Regex to aid with escaping of Windows-specific special chars: - # http://ss64.com/nt/syntax-esc.html - _escape_re = re.compile(r'(?]|(?\^])') - _escaper = partial(_escape_re.sub, lambda m: '^' + m.group(0)) + # Make sure that the $Env:VAR formats come before the $VAR formats since + # Powershell Environment variables are ambiguous with Unix paths. + ENV_VAR_REGEX = re.compile( + "|".join([ + "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR + Shell.ENV_VAR_REGEX.pattern, # Generic form + ]) + ) + + @staticmethod + def _escape_quotes(s): + return s.replace('"', '`"').replace("'", "`'") + + @staticmethod + def _escape_vars(s): + return s.replace('$', '`$') @property def executable(cls): - if cls._executable is None: - cls._executable = Shell.find_executable('powershell') - return cls._executable + raise NotImplementedError @classmethod def name(cls): - return 'powershell' + raise NotImplementedError @classmethod def file_extension(cls): - return 'ps1' + raise NotImplementedError @classmethod def startup_capabilities(cls, @@ -139,6 +154,15 @@ def _bind_interactive_rez(self): if config.set_prompt and self.settings.prompt: self._addline('Function prompt {"%s"}' % self.settings.prompt) + def _additional_commands(self, executor): + # Make .py launch within cmd without extension. + # For PowerShell this will also execute in the same window, so that + # stdout can be captured. + if platform_.name == "windows" and self.settings.additional_pathext: + executor.command('$Env:PATHEXT = $Env:PATHEXT + ";{}"'.format( + ";".join(self.settings.additional_pathext) + )) + def spawn_shell(self, context_file, tmpdir, @@ -177,6 +201,8 @@ def _record_shell(ex, files, bind_rez=True, print_msg=False): files=startup_sequence["files"], print_msg=(not quiet)) + self._additional_commands(executor) + if shell_command: executor.command(shell_command) @@ -198,7 +224,9 @@ def _record_shell(ex, files, bind_rez=True, print_msg=False): cmd = pre_command.rstrip().split() cmd += [self.executable] - cmd += ['. "{}"'.format(target_file)] + + # Generic form of sourcing that works in powershell and pwsh + cmd += ['-File', '{}'.format(target_file)] if shell_command is None: cmd.insert(1, "-noexit") @@ -222,18 +250,17 @@ def get_output(self, style=OutputStyle.file): return script def escape_string(self, value): - """Escape the <, >, ^, and & special characters reserved by Windows. - - Args: - value (str/EscapedString): String or already escaped string. - - Returns: - str: The value escaped for Windows. - - """ - if isinstance(value, EscapedString): - return value.formatted(self._escaper) - return self._escaper(value) + value = EscapedString.promote(value) + value = value.expanduser() + result = '' + + for is_literal, txt in value.strings: + if is_literal: + txt = self._escape_quotes(self._escape_vars(txt)) + else: + txt = self._escape_quotes(txt) + result += txt + return result def _saferefenv(self, key): pass @@ -243,7 +270,15 @@ def shebang(self): def setenv(self, key, value): value = self.escape_string(value) - self._addline('$env:{0} = "{1}"'.format(key, value)) + self._addline('$Env:{0} = "{1}"'.format(key, value)) + + def appendenv(self, key, value): + value = self.escape_string(value) + # Be careful about ambiguous case in pwsh on Linux where pathsep is : + # so that the ${ENV:VAR} form has to be used to not collide. + self._addline( + '$Env:{0} = "${{Env:{0}}}{1}{2}"'.format(key, os.path.pathsep, value) + ) def unsetenv(self, key): self._addline(r"Remove-Item Env:\%s" % key) @@ -276,13 +311,38 @@ def source(self, value): def command(self, value): self._addline(value) - def get_key_token(self, key): - return "$env:%s" % key + @classmethod + def get_key_token(cls, key, form=0): + if form == 1: + return "$Env:%s" % key + else: + return "${Env:%s}" % key + + @classmethod + def key_form_count(cls): + return 2 def join(self, command): return " ".join(command) +class PowerShell(PowerShellBase): + + @property + def executable(cls): + if cls._executable is None: + cls._executable = Shell.find_executable('powershell') + return cls._executable + + @classmethod + def name(cls): + return 'powershell' + + @classmethod + def file_extension(cls): + return 'ps1' + + def register_plugin(): if platform_.name == "windows": return PowerShell diff --git a/src/rezplugins/shell/pwsh.py b/src/rezplugins/shell/pwsh.py index 3641d8ce1..f385782ef 100644 --- a/src/rezplugins/shell/pwsh.py +++ b/src/rezplugins/shell/pwsh.py @@ -1,25 +1,12 @@ """Windows PowerShell 6+""" -from rez.config import config -from rez.rex import RexExecutor, OutputStyle, EscapedString from rez.shells import Shell -from rez.utils.system import popen from rez.utils.platform_ import platform_ -from rez.backport.shutilwhich import which -from functools import partial -from subprocess import PIPE -import os -import re +from rezplugins.shell.powershell import PowerShellBase +from rezplugins.shell.sh import SH -class PowerShell(Shell): - syspaths = None - _executable = None - - # Regex to aid with escaping of Windows-specific special chars: - # http://ss64.com/nt/syntax-esc.html - _escape_re = re.compile(r'(?]|(?\^])') - _escaper = partial(_escape_re.sub, lambda m: '^' + m.group(0)) +class PowerShellCore(PowerShellBase): @property def executable(cls): @@ -35,253 +22,18 @@ def name(cls): def file_extension(cls): return 'ps1' - @classmethod - def startup_capabilities(cls, - rcfile=False, - norc=False, - stdin=False, - command=False): - cls._unsupported_option('rcfile', rcfile) - cls._unsupported_option('norc', norc) - cls._unsupported_option('stdin', stdin) - rcfile = False - norc = False - stdin = False - return (rcfile, norc, stdin, command) - - @classmethod - def get_startup_sequence(cls, rcfile, norc, stdin, command): - rcfile, norc, stdin, command = \ - cls.startup_capabilities(rcfile, norc, stdin, command) - - return dict(stdin=stdin, - command=command, - do_rcfile=False, - envvar=None, - files=[], - bind_files=[], - source_bind_files=(not norc)) - @classmethod def get_syspaths(cls): - if cls.syspaths is not None: - return cls.syspaths - - if config.standard_system_paths: - cls.syspaths = config.standard_system_paths - return cls.syspaths - - # detect system paths using registry - def gen_expected_regex(parts): - whitespace = r"[\s]+" - return whitespace.join(parts) - - paths = [] - - cmd = [ - "REG", "QUERY", - ("HKLM\\SYSTEM\\CurrentControlSet\\" - "Control\\Session Manager\\Environment"), "/v", "PATH" - ] - - expected = gen_expected_regex([ - ("HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\CurrentControlSet\\\\" - "Control\\\\Session Manager\\\\Environment"), "PATH", - "REG_(EXPAND_)?SZ", "(.*)" - ]) - - p = popen(cmd, - stdout=PIPE, - stderr=PIPE, - universal_newlines=True, - shell=True) - out_, _ = p.communicate() - out_ = out_.strip() - - if p.returncode == 0: - match = re.match(expected, out_) - if match: - paths.extend(match.group(2).split(os.pathsep)) - - cmd = ["REG", "QUERY", "HKCU\\Environment", "/v", "PATH"] - - expected = gen_expected_regex([ - "HKEY_CURRENT_USER\\\\Environment", "PATH", "REG_(EXPAND_)?SZ", - "(.*)" - ]) - - p = popen(cmd, - stdout=PIPE, - stderr=PIPE, - universal_newlines=True, - shell=True) - out_, _ = p.communicate() - out_ = out_.strip() - - if p.returncode == 0: - match = re.match(expected, out_) - if match: - paths.extend(match.group(2).split(os.pathsep)) - - cls.syspaths = list(set([x for x in paths if x])) - - # add Rez binaries - exe = which("rez-env") - assert exe, "Could not find rez binary, this is a bug" - rez_bin_dir = os.path.dirname(exe) - cls.syspaths.insert(0, rez_bin_dir) - - return cls.syspaths - - def _bind_interactive_rez(self): - if config.set_prompt and self.settings.prompt: - self._addline('Function prompt {"%s"}' % self.settings.prompt) - - def spawn_shell(self, - context_file, - tmpdir, - rcfile=None, - norc=False, - stdin=False, - command=None, - env=None, - quiet=False, - pre_command=None, - **Popen_args): - - startup_sequence = self.get_startup_sequence(rcfile, norc, bool(stdin), - command) - shell_command = None - - def _record_shell(ex, files, bind_rez=True, print_msg=False): - ex.source(context_file) - if startup_sequence["envvar"]: - ex.unsetenv(startup_sequence["envvar"]) - if bind_rez: - ex.interpreter._bind_interactive_rez() - if print_msg and not quiet: - # Rez may not be available - ex.command("Try { rez context } Catch { }") - - executor = RexExecutor(interpreter=self.new_shell(), - parent_environ={}, - add_default_namespaces=False) - - if startup_sequence["command"] is not None: - _record_shell(executor, files=startup_sequence["files"]) - shell_command = startup_sequence["command"] - else: - _record_shell(executor, - files=startup_sequence["files"], - print_msg=(not quiet)) - - if shell_command: - executor.command(shell_command) - - # Forward exit call to parent PowerShell process - executor.command("exit $LastExitCode") - - code = executor.get_output() - target_file = os.path.join(tmpdir, - "rez-shell.%s" % self.file_extension()) - - with open(target_file, 'w') as f: - f.write(code) - - cmd = [] - if pre_command: - cmd = pre_command - - if not isinstance(cmd, (tuple, list)): - cmd = pre_command.rstrip().split() - - cmd += [self.executable] - cmd += ['{}'.format(target_file)] - - if shell_command is None: - cmd.insert(1, "-noexit") - - p = popen(cmd, env=env, universal_newlines=True, **Popen_args) - return p - - def get_output(self, style=OutputStyle.file): - if style == OutputStyle.file: - script = '\n'.join(self._lines) + '\n' + # TODO: Clean dependency from SH + if platform_.name == "windows": + return super(PowerShellCore, cls).get_syspaths() else: - lines = [] - for line in self._lines: - if line.startswith('#'): - continue - - line = line.rstrip() - lines.append(line) - - script = '&& '.join(lines) - return script - - def escape_string(self, value): - """Escape the <, >, ^, and & special characters reserved by Windows. - - Args: - value (str/EscapedString): String or already escaped string. - - Returns: - str: The value escaped for Windows. - - """ - if isinstance(value, EscapedString): - return value.formatted(self._escaper) - return self._escaper(value) - - def _saferefenv(self, key): - pass - - def shebang(self): - pass - - def setenv(self, key, value): - value = self.escape_string(value) - self._addline('$env:{0} = "{1}"'.format(key, value)) - - def unsetenv(self, key): - self._addline(r"Remove-Item Env:\%s" % key) - - def resetenv(self, key, value, friends=None): - self._addline(self.setenv(key, value)) - - def alias(self, key, value): - value = EscapedString.disallow(value) - cmd = "function {key}() {{ {value} $args }}" - self._addline(cmd.format(key=key, value=value)) - - def comment(self, value): - for line in value.split('\n'): - self._addline('# %s' % line) - - def info(self, value): - for line in value.split('\n'): - self._addline('Write-Host %s' % line) - - def error(self, value): - for line in value.split('\n'): - self._addline('Write-Error "%s"' % line) - - def source(self, value): - self._addline(". \"%s\"" % value) - - def command(self, value): - self._addline(value) - - def get_key_token(self, key): - return "$env:%s" % key - - def join(self, command): - return " ".join(command) + return SH.get_syspaths() def register_plugin(): - if platform_.name == "windows": - return PowerShell + # Platform independent + return PowerShellCore # Copyright 2013-2016 Allan Johns. diff --git a/src/rezplugins/shell/rezconfig b/src/rezplugins/shell/rezconfig index 8e40dc610..271050eef 100644 --- a/src/rezplugins/shell/rezconfig +++ b/src/rezplugins/shell/rezconfig @@ -15,9 +15,12 @@ zsh: cmd: prompt: '$G' + additional_pathext: ['.PY'] powershell: prompt: '> $ ' + additional_pathext: ['.PY'] pwsh: prompt: '> $ ' + additional_pathext: ['.PY'] From d539fcf0043454c09b25b48f9e2c8df70c9e49e1 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Mon, 26 Aug 2019 10:47:03 -0400 Subject: [PATCH 02/17] Show shell-name in failing `shell_dependend` tests --- src/rez/tests/util.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/rez/tests/util.py b/src/rez/tests/util.py index bbda33c66..7f9bd455a 100644 --- a/src/rez/tests/util.py +++ b/src/rez/tests/util.py @@ -175,7 +175,14 @@ def wrapper(self, *args, **kwargs): self.skipTest("This test does not run on %s shell." % shell) print("\ntesting in shell: %s..." % shell) config.override("default_shell", shell) - func(self, *args, **kwargs) + try: + func(self, *args, **kwargs) + except AssertionError as e: + # Add the shell to the exception message + args = list(e.args) + args[0] += " (in shell '{}')".format(shell) + e.args = tuple(args) + raise return wrapper return decorator From 97e3fc4a747400ff31030a58bca55b6c6fe3c03c Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Mon, 26 Aug 2019 10:52:33 -0400 Subject: [PATCH 03/17] Whitespace fixes for cmd. --- src/rezplugins/shell/cmd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index e486a10c9..b49910ff7 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -329,7 +329,8 @@ def key_form_count(cls): return 1 def join(self, command): - return " ".join(command) + # Surround with quotes if includes whitespaces + return " ".join(['"{}"'.format(x) if " " in x else x for x in command]) def register_plugin(): From 07d07cce65b861faebb0b132d9c0ce0a767e82e6 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Mon, 26 Aug 2019 11:02:02 -0400 Subject: [PATCH 04/17] Revert "Reduce levels of shells from 2 to 1" This introduced quotation issues as detailed in #691 This reverts commit e513b690764a29ee60eecd45a4912ce7906a8fd2. # Conflicts: # src/rezplugins/shell/cmd.py --- src/rezplugins/shell/cmd.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index b49910ff7..78acee6bb 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -201,6 +201,22 @@ def _create_ex(): else: _record_shell(executor, files=startup_sequence["files"], print_msg=(not quiet)) + if shell_command: + # Launch the provided command in the configured shell and wait + # until it exits. + executor.command(shell_command) + + # Test for None specifically because resolved_context.execute_rex_code + # passes '' and we do NOT want to keep a shell open during a rex code + # exec operation. + elif shell_command is None: + # Launch the configured shell itself and wait for user interaction + # to exit. + executor.command('cmd /Q /K') + + # Exit the configured shell. + executor.command('exit %errorlevel%') + code = executor.get_output() target_file = os.path.join(tmpdir, "rez-shell.%s" % self.file_extension()) @@ -229,10 +245,6 @@ def _create_ex(): cmd += [self.executable] cmd += cmd_flags cmd += ['call {}'.format(target_file)] - - if shell_command: - cmd += ["& " + shell_command] - is_detached = (cmd[0] == 'START') p = popen(cmd, env=env, shell=is_detached, **Popen_args) From 8923971a1eb9da124ea03d02c0649aab99b39a6d Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Wed, 28 Aug 2019 11:23:46 -0400 Subject: [PATCH 05/17] Replaces shell join implementation with Python's default implementation. It should be noted that according to [1] this is not an officially exposed API, but in order to avoid License clearance for now I just used it similar like we use pipes.quote in `shells.py`. [1] https://bugs.python.org/issue10838 --- src/rezplugins/shell/cmd.py | 7 ++++--- src/rezplugins/shell/powershell.py | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 78acee6bb..23c054330 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -7,7 +7,7 @@ from rez.system import system from rez.utils.system import popen from rez.utils.platform_ import platform_ -from rez.util import shlex_join +from rezplugins.shell.powershell import list2cmdline from rez.vendor.six import six from functools import partial import os @@ -341,8 +341,9 @@ def key_form_count(cls): return 1 def join(self, command): - # Surround with quotes if includes whitespaces - return " ".join(['"{}"'.format(x) if " " in x else x for x in command]) + # TODO: This may disappear in future [1] + # [1] https://bugs.python.org/issue10838 + return subprocess.list2cmdline(command) def register_plugin(): diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 7d05448b8..18865a6b7 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -7,7 +7,7 @@ from rez.utils.platform_ import platform_ from rez.backport.shutilwhich import which from functools import partial -from subprocess import PIPE +from subprocess import PIPE, list2cmdline import os import re @@ -323,7 +323,9 @@ def key_form_count(cls): return 2 def join(self, command): - return " ".join(command) + # TODO: This may disappear in future [1] + # [1] https://bugs.python.org/issue10838 + return list2cmdline(command) class PowerShell(PowerShellBase): From 704d9c801e3277384bee61b20b4a1b53076e9a8d Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 13:46:49 -0400 Subject: [PATCH 06/17] Minor fixes and naming. --- src/rez/bind/hello_world.py | 3 --- src/rez/rex.py | 4 +-- src/rez/rezconfig.py | 43 +++++++++++++----------------- src/rez/tests/test_shells.py | 17 +++--------- src/rez/util.py | 7 ++--- src/rezplugins/shell/powershell.py | 12 --------- 6 files changed, 28 insertions(+), 58 deletions(-) diff --git a/src/rez/bind/hello_world.py b/src/rez/bind/hello_world.py index cb100a265..e345134a6 100644 --- a/src/rez/bind/hello_world.py +++ b/src/rez/bind/hello_world.py @@ -44,9 +44,6 @@ def make_root(variant, root): binpath = make_dirs(root, "bin") filepath = os.path.join(binpath, "hello_world") - # In order for this script to run on each platform we create the - # platform-specific script. This also requires the additional_pathext - # setting of the windows shell plugins to include ".PY" create_executable_script( filepath, hello_world_source, diff --git a/src/rez/rex.py b/src/rez/rex.py index a0093c238..fb4c80721 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -899,9 +899,7 @@ def escape_envvar(matchobj): value = next((x for x in matchobj.groups() if x is not None)) return "${{%s}}" % value - regex = ActionInterpreter.ENV_VAR_REGEX - if "regex" in kwargs: - regex = kwargs["regex"] + regex = kwargs.get("regex") or ActionInterpreter.ENV_VAR_REGEX format_string_ = re.sub(regex, escape_envvar, format_string) diff --git a/src/rez/rezconfig.py b/src/rez/rezconfig.py index 44af802b3..5a014fc95 100644 --- a/src/rez/rezconfig.py +++ b/src/rez/rezconfig.py @@ -619,29 +619,6 @@ suite_alias_prefix_char = "+" -############################################################################### -# Utils -############################################################################### - -# Default option on how to create scripts with util.create_executable_script. -# In order to support both windows and other OS it is recommended to set this -# to 'both'. -# -# Possible modes: -# - requested: -# Requested shebang script only. Usually extension-less. -# - py: -# Create .py script that will allow launching scripts on windows, -# if the shell adds .py to PATHEXT. Make sure to use PEP-397 py.exe -# as default application for .py files. -# - platform_specific: -# Will create py script on windows and requested on other platforms -# - both: -# Creates the requested file and a .py script so that scripts can be -# launched without extension from windows and other systems. -create_executable_script_mode = "requested" - - ############################################################################### # Appearance ############################################################################### @@ -699,6 +676,25 @@ # If not zero, truncates all package changelogs to only show the last N commits max_package_changelog_revisions = 0 +# Default option on how to create scripts with util.create_executable_script. +# In order to support both windows and other OS it is recommended to set this +# to 'both'. +# +# Possible modes: +# - single: +# Creates the requested script only. +# - py: +# Create .py script that will allow launching scripts on windows, +# if the shell adds .py to PATHEXT. Make sure to use PEP-397 py.exe +# as default application for .py files. +# - platform_specific: +# Will create py script on windows and requested on other platforms +# - both: +# Creates the requested file and a .py script so that scripts can be +# launched without extension from windows and other systems. +create_executable_script_mode = "single" + + ############################################################################### # Rez-1 Compatibility ############################################################################### @@ -898,7 +894,6 @@ } - ############################################################################### ############################################################################### # GUI diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index fff4717f9..18c4c98d3 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -81,12 +81,12 @@ def test_create_executable_script(self): self.assertListEqual(files, [py_script_file]) files = _get_python_script_files(script_file, - ExecutableScriptMode.requested, + ExecutableScriptMode.single, platform) self.assertListEqual(files, [script_file]) files = _get_python_script_files(py_script_file, - ExecutableScriptMode.requested, + ExecutableScriptMode.single, platform) self.assertListEqual(files, [py_script_file]) @@ -120,8 +120,7 @@ def test_command(self): if command: r = self._create_context(["hello_world"]) - script = "hello_world" - p = r.execute_shell(command=script, + p = r.execute_shell(command="hello_world", stdout=subprocess.PIPE) self.assertEqual(_stdout(p), "Hello Rez World!") @@ -188,16 +187,8 @@ def test_rez_env_output(self): # here we are making sure that running a command via rez-env prints # exactly what we expect. sh = create_shell() - echo_cmd = which("echo") - - # Certain shells will not find echo. - # TODO: If this exception was created for the following shells then - # it is redundant. Can we clarify which platforms this was meant for? - has_buildin_echo = sh.name() in ['cmd', 'powershell', 'pwsh'] - if not echo_cmd and not has_buildin_echo: - print("\nskipping test, 'echo' command not found.") - return + # Assumes that the shell has an echo command, build-in or alias cmd = [os.path.join(system.rez_bin_path, "rez-env"), "--", "echo", "hey"] process = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) sh_out, _ = process.communicate() diff --git a/src/rez/util.py b/src/rez/util.py index 07e09e0b9..e0ef31d10 100644 --- a/src/rez/util.py +++ b/src/rez/util.py @@ -23,8 +23,8 @@ class ExecutableScriptMode(Enum): """ # Start with 1 to not collide with None checks - # Requested shebang script only. Usually extension-less. - requested = 1 + # Requested script only. Usually extension-less. + single = 1 # Create .py script that will allow launching scripts on # windows without extension, but may require extension on @@ -126,6 +126,7 @@ def _get_python_script_files(filepath, py_script_mode, platform): platform (str): Platform to evaluate the script files for Returns: + list of str: filepaths of scripts to create based on inputs """ script_filepaths = [] @@ -133,7 +134,7 @@ def _get_python_script_files(filepath, py_script_mode, platform): has_py_ext = extension == ".py" is_windows = platform == "windows" - if py_script_mode == ExecutableScriptMode.requested or \ + if py_script_mode == ExecutableScriptMode.single or \ py_script_mode == ExecutableScriptMode.both or \ (py_script_mode == ExecutableScriptMode.py and has_py_ext) or \ (py_script_mode == ExecutableScriptMode.platform_specific and diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 18865a6b7..e8e5ad462 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -38,18 +38,6 @@ def _escape_quotes(s): def _escape_vars(s): return s.replace('$', '`$') - @property - def executable(cls): - raise NotImplementedError - - @classmethod - def name(cls): - raise NotImplementedError - - @classmethod - def file_extension(cls): - raise NotImplementedError - @classmethod def startup_capabilities(cls, rcfile=False, From 31595a79f8ea85aa6b58d658342a516d9904acc6 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 13:50:01 -0400 Subject: [PATCH 07/17] Makes `Shell.join` classmethod. --- src/rez/shells.py | 6 ++++-- src/rezplugins/shell/cmd.py | 3 ++- src/rezplugins/shell/powershell.py | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/rez/shells.py b/src/rez/shells.py index c43e67c63..c527a6485 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -187,7 +187,8 @@ def key_form_count(cls): """ raise NotImplementedError - def join(self, command): + @classmethod + def join(cls, command): """ Args: command: @@ -435,7 +436,8 @@ def get_key_token(cls, key, form=0): def key_form_count(cls): return 2 - def join(self, command): + @classmethod + def join(cls, command): return shlex_join(command) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 23c054330..f85cd7324 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -340,7 +340,8 @@ def get_key_token(cls, key, form=0): def key_form_count(cls): return 1 - def join(self, command): + @classmethod + def join(cls, command): # TODO: This may disappear in future [1] # [1] https://bugs.python.org/issue10838 return subprocess.list2cmdline(command) diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index e8e5ad462..ee4fb8226 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -310,7 +310,8 @@ def get_key_token(cls, key, form=0): def key_form_count(cls): return 2 - def join(self, command): + @classmethod + def join(cls, command): # TODO: This may disappear in future [1] # [1] https://bugs.python.org/issue10838 return list2cmdline(command) From 9a996662714a5582ab14908504b7136191771860 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 13:53:38 -0400 Subject: [PATCH 08/17] Add `Shell.line_terminator`. Line termination is not necessarirly platform dependent, but also shell dependent. --- src/rez/shells.py | 11 +++++++++++ src/rez/tests/test_shells.py | 3 +-- src/rezplugins/shell/cmd.py | 4 ++++ src/rezplugins/shell/powershell.py | 4 ++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/rez/shells.py b/src/rez/shells.py index c527a6485..afb81fdb4 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -187,6 +187,14 @@ def key_form_count(cls): """ raise NotImplementedError + @classmethod + def line_terminator(cls): + """ + Returns: + str: default line terminator + """ + raise NotImplementedError + @classmethod def join(cls, command): """ @@ -440,6 +448,9 @@ def key_form_count(cls): def join(cls, command): return shlex_join(command) + @classmethod + def line_terminator(cls): + return "\n" # Copyright 2013-2016 Allan Johns. # diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index 18c4c98d3..03db8c04d 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -226,9 +226,8 @@ def _execute_code(func, expected_output): # Powershell and Unix uses \n sh = create_shell() - token = '\r\n' if sh.name() == 'cmd' else '\n' - output = out.strip().split(token) + output = out.strip().split(sh.line_terminator()) self.assertEqual(output, expected_output) def _rex_assigning(): diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index f85cd7324..347c6b089 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -346,6 +346,10 @@ def join(cls, command): # [1] https://bugs.python.org/issue10838 return subprocess.list2cmdline(command) + @classmethod + def line_terminator(cls): + return "\r\n" + def register_plugin(): if platform_.name == "windows": diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index ee4fb8226..6e3b31479 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -316,6 +316,10 @@ def join(cls, command): # [1] https://bugs.python.org/issue10838 return list2cmdline(command) + @classmethod + def line_terminator(cls): + return "\n" + class PowerShell(PowerShellBase): From 2ab966ade33a6c70e3ec24da6257ae87f91c41b6 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 13:56:00 -0400 Subject: [PATCH 09/17] Properly fix rex shell tests by introducing `Shell.convert_tokens`. Shells which do not use the generic ${VAR} or $VAR form should use convert_token to convert to their native form. This only assumes that a shell correctly extends ENV_VAR_REGEX to also parse their own variables. --- src/rez/shells.py | 18 ++++++++++++ src/rez/tests/test_shells.py | 44 ++++++++++++------------------ src/rezplugins/shell/cmd.py | 5 +++- src/rezplugins/shell/powershell.py | 9 ++++-- 4 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/rez/shells.py b/src/rez/shells.py index afb81fdb4..65e261cdd 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -75,6 +75,23 @@ def __init__(self): def _addline(self, line): self._lines.append(line) + def convert_tokens(self, value): + """ + Converts any token form like ${VAR} and $VAR to shell specific + form. Uses the ENV_VAR_REGEX class variable to correctly parse + variables. + + Args: + value: str to convert + + Returns: + str with shell specific variables + """ + return self.ENV_VAR_REGEX.sub( + lambda m: "".join(self.get_key_token(g) for g in m.groups() if g), + value + ) + def get_output(self, style=OutputStyle.file): if style == OutputStyle.file: script = '\n'.join(self._lines) + '\n' @@ -222,6 +239,7 @@ class UnixShell(Shell): last_command_status = '$?' syspaths = None + # # startup rules # diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index 03db8c04d..823432bbd 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -231,9 +231,8 @@ def _execute_code(func, expected_output): self.assertEqual(output, expected_output) def _rex_assigning(): - from rez.shells import create_shell, UnixShell + from rez.shells import create_shell sh = create_shell() - is_powershell = sh.name() in ["powershell", "pwsh"] def _print(value): env.FOO = value @@ -241,10 +240,7 @@ def _print(value): # interpreting parts of our output as commands. This can happen # when we include special characters (&, <, >, ^) in a # variable. - if is_powershell: - info('`"{}`"'.format(sh.get_key_token("FOO"))) - else: - info('"{}"'.format(sh.get_key_token("FOO"))) + info('"${FOO}"') env.GREET = "hi" env.WHO = "Gary" @@ -282,15 +278,12 @@ def _print(value): _print('hey ^ world') # Platform dependent form of variables. - # No need to test in unix shells since their for, matches the - # generic form $VAR and ${VAR}. - if not isinstance(sh, UnixShell): - for i in range(sh.key_form_count()): - _print("hey " + sh.get_key_token("WHO", i)) - _print(expandable("${GREET} ").e(sh.get_key_token("WHO", i))) - _print(expandable("${GREET} ").l(sh.get_key_token("WHO", i))) - _print(literal(sh.get_key_token("WHO", i))) - _print(literal(sh.get_key_token("WHO", i)).e(" " + sh.get_key_token("WHO", i))) + for i in range(sh.key_form_count()): + _print("hey " + sh.get_key_token("WHO", i)) + _print(expandable("${GREET} ").e(sh.get_key_token("WHO", i))) + _print(expandable("${GREET} ").l(sh.get_key_token("WHO", i))) + _print(literal(sh.get_key_token("WHO", i))) + _print(literal(sh.get_key_token("WHO", i)).e(" " + sh.get_key_token("WHO", i))) expected_output = [ "ello", @@ -323,19 +316,16 @@ def _print(value): ] # Assertions for other environment variable types - from rez.shells import create_shell, UnixShell + from rez.shells import create_shell sh = create_shell() - if not isinstance(sh, UnixShell): - from rez.shells import create_shell, UnixShell - sh = create_shell() - for i in range(sh.key_form_count()): - expected_output += [ - "hey Gary", - "hi Gary", - "hi " + sh.get_key_token("WHO", i), - sh.get_key_token("WHO", i), - sh.get_key_token("WHO", i) + " Gary", - ] + for i in range(sh.key_form_count()): + expected_output += [ + "hey Gary", + "hi Gary", + "hi " + sh.get_key_token("WHO", i), + sh.get_key_token("WHO", i), + sh.get_key_token("WHO", i) + " Gary", + ] # We are wrapping all variable outputs in quotes in order to make sure # our shell isn't interpreting our output as instructions when echoing diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 347c6b089..58810ffa1 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -7,7 +7,6 @@ from rez.system import system from rez.utils.system import popen from rez.utils.platform_ import platform_ -from rezplugins.shell.powershell import list2cmdline from rez.vendor.six import six from functools import partial import os @@ -320,10 +319,14 @@ def comment(self, value): def info(self, value): for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) self._addline('echo %s' % line) def error(self, value): for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) self._addline('echo "%s" 1>&2' % line) def source(self, value): diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 6e3b31479..98684d671 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -25,8 +25,9 @@ class PowerShellBase(Shell): # Powershell Environment variables are ambiguous with Unix paths. ENV_VAR_REGEX = re.compile( "|".join([ - "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR - Shell.ENV_VAR_REGEX.pattern, # Generic form + "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR + "\\${[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)}", # ${Env:ENVVAR} + Shell.ENV_VAR_REGEX.pattern, # Generic form ]) ) @@ -287,10 +288,14 @@ def comment(self, value): def info(self, value): for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) self._addline('Write-Host %s' % line) def error(self, value): for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) self._addline('Write-Error "%s"' % line) def source(self, value): From 0ccaa907031f4def012c5c9743dc3243a1667c4c Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 14:12:59 -0400 Subject: [PATCH 10/17] Better API for multiple shell key token forms. --- src/rez/shells.py | 30 +++++++++++------------ src/rez/tests/test_shells.py | 39 ++++++++---------------------- src/rezplugins/shell/cmd.py | 8 ++---- src/rezplugins/shell/powershell.py | 11 ++------- 4 files changed, 29 insertions(+), 59 deletions(-) diff --git a/src/rez/shells.py b/src/rez/shells.py index 65e261cdd..32294320b 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -182,25 +182,32 @@ def spawn_shell(self, context_file, tmpdir, rcfile=None, norc=False, raise NotImplementedError @classmethod - def get_key_token(cls, key, form=0): + def get_key_token(cls, key): """ Encodes the environment variable into the shell specific form. Shells might implement multiple forms, but the most common/safest - should be implemented as form 0 or if the form exceeds key_form_count. + should be returned here. Args: key: Variable name to encode - form: number of token form Returns: str of encoded token form """ - raise NotImplementedError + return cls.get_all_key_tokens(key)[0] @classmethod - def key_form_count(cls): + def get_all_key_tokens(cls, key): """ - Returns: Number of forms get_key_token supports + Encodes the environment variable into the shell specific forms. + Shells might implement multiple forms, but the most common/safest + should be always returned at index 0. + + Args: + key: Variable name to encode + + Returns: + list of str with encoded token forms """ raise NotImplementedError @@ -452,15 +459,8 @@ def shebang(self): self._addline("#!%s" % self.executable) @classmethod - def get_key_token(cls, key, form=0): - if form == 1: - return "$%s" % key - else: - return "${%s}" % key - - @classmethod - def key_form_count(cls): - return 2 + def get_all_key_tokens(cls, key): + return ["${%s}" % key, "$%s" % key] @classmethod def join(cls, command): diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index 823432bbd..5b6d7efb0 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -278,12 +278,12 @@ def _print(value): _print('hey ^ world') # Platform dependent form of variables. - for i in range(sh.key_form_count()): - _print("hey " + sh.get_key_token("WHO", i)) - _print(expandable("${GREET} ").e(sh.get_key_token("WHO", i))) - _print(expandable("${GREET} ").l(sh.get_key_token("WHO", i))) - _print(literal(sh.get_key_token("WHO", i))) - _print(literal(sh.get_key_token("WHO", i)).e(" " + sh.get_key_token("WHO", i))) + for token in sh.get_all_key_tokens("WHO"): + _print("hey " + token) + _print(expandable("${GREET} ").e(token)) + _print(expandable("${GREET} ").l(token)) + _print(literal(token)) + _print(literal(token).e(" " + token)) expected_output = [ "ello", @@ -318,13 +318,13 @@ def _print(value): # Assertions for other environment variable types from rez.shells import create_shell sh = create_shell() - for i in range(sh.key_form_count()): + for token in sh.get_all_key_tokens("WHO"): expected_output += [ "hey Gary", "hi Gary", - "hi " + sh.get_key_token("WHO", i), - sh.get_key_token("WHO", i), - sh.get_key_token("WHO", i) + " Gary", + "hi " + token, + token, + token + " Gary", ] # We are wrapping all variable outputs in quotes in order to make sure @@ -353,25 +353,6 @@ def _rex_appending(): _execute_code(_rex_appending, expected_output) - @shell_dependent() - def test_variable_encoding(self): - """ - Sanity test so we can make use of get_key_token in other tests. - """ - sh = create_shell() - name = sh.name() - if name == "cmd": - self.assertEqual("%HELLO%", sh.get_key_token("HELLO", 0)) - self.assertEqual(1, sh.key_form_count()) - elif name == "powershell" or name == "pwsh": - self.assertEqual("${Env:HELLO}", sh.get_key_token("HELLO", 0)) - self.assertEqual("$Env:HELLO", sh.get_key_token("HELLO", 1)) - self.assertEqual(2, sh.key_form_count()) - else: - self.assertEqual("${HELLO}", sh.get_key_token("HELLO", 0)) - self.assertEqual("$HELLO", sh.get_key_token("HELLO", 1)) - self.assertEqual(2, sh.key_form_count()) - @shell_dependent() def test_rex_code_alias(self): """Ensure PATH changes do not influence the alias command. diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 58810ffa1..97bc3d105 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -336,12 +336,8 @@ def command(self, value): self._addline(value) @classmethod - def get_key_token(cls, key, form=0): - return "%{}%".format(key) - - @classmethod - def key_form_count(cls): - return 1 + def get_all_key_tokens(cls, key): + return ["%{}%".format(key)] @classmethod def join(cls, command): diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 98684d671..12b112c5d 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -305,15 +305,8 @@ def command(self, value): self._addline(value) @classmethod - def get_key_token(cls, key, form=0): - if form == 1: - return "$Env:%s" % key - else: - return "${Env:%s}" % key - - @classmethod - def key_form_count(cls): - return 2 + def get_all_key_tokens(cls, key): + return ["${Env:%s}" % key, "$Env:%s" % key] @classmethod def join(cls, command): From 337b9f826fcf15766c1d1c1359847d64d4b8b995 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 14:56:37 -0400 Subject: [PATCH 11/17] Explaining the dependency of pwsh on SH on non-windows platforms. --- src/rezplugins/shell/pwsh.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/rezplugins/shell/pwsh.py b/src/rezplugins/shell/pwsh.py index f385782ef..6f809121b 100644 --- a/src/rezplugins/shell/pwsh.py +++ b/src/rezplugins/shell/pwsh.py @@ -3,7 +3,6 @@ from rez.shells import Shell from rez.utils.platform_ import platform_ from rezplugins.shell.powershell import PowerShellBase -from rezplugins.shell.sh import SH class PowerShellCore(PowerShellBase): @@ -24,10 +23,15 @@ def file_extension(cls): @classmethod def get_syspaths(cls): - # TODO: Clean dependency from SH if platform_.name == "windows": return super(PowerShellCore, cls).get_syspaths() else: + # TODO: Newer versions of pwsh will parse .profile via sh [1], so + # we could use a similar technique as SH itself. For now, to + # support older pwsh version we depend on SH on unix like platforms + # directly. + # [1] https://github.com/PowerShell/PowerShell/pull/10050 + from rezplugins.shell.sh import SH return SH.get_syspaths() From 99e6f535db76ff6c49e64be4db6ea57cba075836 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 15:00:23 -0400 Subject: [PATCH 12/17] Moved PowershellBase in sub-module. --- src/rezplugins/shell/powershell.py | 316 +---------------- .../shell/powershell_common/__init__.py | 0 .../powershell_common/powershell_base.py | 317 ++++++++++++++++++ src/rezplugins/shell/pwsh.py | 2 +- 4 files changed, 319 insertions(+), 316 deletions(-) create mode 100644 src/rezplugins/shell/powershell_common/__init__.py create mode 100644 src/rezplugins/shell/powershell_common/powershell_base.py diff --git a/src/rezplugins/shell/powershell.py b/src/rezplugins/shell/powershell.py index 12b112c5d..565c049a6 100644 --- a/src/rezplugins/shell/powershell.py +++ b/src/rezplugins/shell/powershell.py @@ -1,322 +1,8 @@ """Windows PowerShell 5""" -from rez.config import config -from rez.rex import RexExecutor, OutputStyle, EscapedString, literal from rez.shells import Shell -from rez.utils.system import popen from rez.utils.platform_ import platform_ -from rez.backport.shutilwhich import which -from functools import partial -from subprocess import PIPE, list2cmdline -import os -import re - - -class PowerShellBase(Shell): - """ - Abstract base class for Powershell-like shells. - """ - expand_env_vars = True - - syspaths = None - _executable = None - - # Make sure that the $Env:VAR formats come before the $VAR formats since - # Powershell Environment variables are ambiguous with Unix paths. - ENV_VAR_REGEX = re.compile( - "|".join([ - "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR - "\\${[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)}", # ${Env:ENVVAR} - Shell.ENV_VAR_REGEX.pattern, # Generic form - ]) - ) - - @staticmethod - def _escape_quotes(s): - return s.replace('"', '`"').replace("'", "`'") - - @staticmethod - def _escape_vars(s): - return s.replace('$', '`$') - - @classmethod - def startup_capabilities(cls, - rcfile=False, - norc=False, - stdin=False, - command=False): - cls._unsupported_option('rcfile', rcfile) - cls._unsupported_option('norc', norc) - cls._unsupported_option('stdin', stdin) - rcfile = False - norc = False - stdin = False - return (rcfile, norc, stdin, command) - - @classmethod - def get_startup_sequence(cls, rcfile, norc, stdin, command): - rcfile, norc, stdin, command = \ - cls.startup_capabilities(rcfile, norc, stdin, command) - - return dict(stdin=stdin, - command=command, - do_rcfile=False, - envvar=None, - files=[], - bind_files=[], - source_bind_files=(not norc)) - - @classmethod - def get_syspaths(cls): - if cls.syspaths is not None: - return cls.syspaths - - if config.standard_system_paths: - cls.syspaths = config.standard_system_paths - return cls.syspaths - - # detect system paths using registry - def gen_expected_regex(parts): - whitespace = r"[\s]+" - return whitespace.join(parts) - - # TODO: Research if there is an easier way to pull system PATH from - # registry in powershell - paths = [] - - cmd = [ - "REG", "QUERY", - ("HKLM\\SYSTEM\\CurrentControlSet\\" - "Control\\Session Manager\\Environment"), "/v", "PATH" - ] - - expected = gen_expected_regex([ - ("HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\CurrentControlSet\\\\" - "Control\\\\Session Manager\\\\Environment"), "PATH", - "REG_(EXPAND_)?SZ", "(.*)" - ]) - - p = popen(cmd, - stdout=PIPE, - stderr=PIPE, - universal_newlines=True, - shell=True) - out_, _ = p.communicate() - out_ = out_.strip() - - if p.returncode == 0: - match = re.match(expected, out_) - if match: - paths.extend(match.group(2).split(os.pathsep)) - - cmd = ["REG", "QUERY", "HKCU\\Environment", "/v", "PATH"] - - expected = gen_expected_regex([ - "HKEY_CURRENT_USER\\\\Environment", "PATH", "REG_(EXPAND_)?SZ", - "(.*)" - ]) - - p = popen(cmd, - stdout=PIPE, - stderr=PIPE, - universal_newlines=True, - shell=True) - out_, _ = p.communicate() - out_ = out_.strip() - - if p.returncode == 0: - match = re.match(expected, out_) - if match: - paths.extend(match.group(2).split(os.pathsep)) - - cls.syspaths = list(set([x for x in paths if x])) - - # add Rez binaries - exe = which("rez-env") - assert exe, "Could not find rez binary, this is a bug" - rez_bin_dir = os.path.dirname(exe) - cls.syspaths.insert(0, rez_bin_dir) - - return cls.syspaths - - def _bind_interactive_rez(self): - if config.set_prompt and self.settings.prompt: - self._addline('Function prompt {"%s"}' % self.settings.prompt) - - def _additional_commands(self, executor): - # Make .py launch within cmd without extension. - # For PowerShell this will also execute in the same window, so that - # stdout can be captured. - if platform_.name == "windows" and self.settings.additional_pathext: - executor.command('$Env:PATHEXT = $Env:PATHEXT + ";{}"'.format( - ";".join(self.settings.additional_pathext) - )) - - def spawn_shell(self, - context_file, - tmpdir, - rcfile=None, - norc=False, - stdin=False, - command=None, - env=None, - quiet=False, - pre_command=None, - **Popen_args): - - startup_sequence = self.get_startup_sequence(rcfile, norc, bool(stdin), - command) - shell_command = None - - def _record_shell(ex, files, bind_rez=True, print_msg=False): - ex.source(context_file) - if startup_sequence["envvar"]: - ex.unsetenv(startup_sequence["envvar"]) - if bind_rez: - ex.interpreter._bind_interactive_rez() - if print_msg and not quiet: - # Rez may not be available - ex.command("Try { rez context } Catch { }") - - executor = RexExecutor(interpreter=self.new_shell(), - parent_environ={}, - add_default_namespaces=False) - - if startup_sequence["command"] is not None: - _record_shell(executor, files=startup_sequence["files"]) - shell_command = startup_sequence["command"] - else: - _record_shell(executor, - files=startup_sequence["files"], - print_msg=(not quiet)) - - self._additional_commands(executor) - - if shell_command: - executor.command(shell_command) - - # Forward exit call to parent PowerShell process - executor.command("exit $LastExitCode") - - code = executor.get_output() - target_file = os.path.join(tmpdir, - "rez-shell.%s" % self.file_extension()) - - with open(target_file, 'w') as f: - f.write(code) - - cmd = [] - if pre_command: - cmd = pre_command - - if not isinstance(cmd, (tuple, list)): - cmd = pre_command.rstrip().split() - - cmd += [self.executable] - - # Generic form of sourcing that works in powershell and pwsh - cmd += ['-File', '{}'.format(target_file)] - - if shell_command is None: - cmd.insert(1, "-noexit") - - p = popen(cmd, env=env, universal_newlines=True, **Popen_args) - return p - - def get_output(self, style=OutputStyle.file): - if style == OutputStyle.file: - script = '\n'.join(self._lines) + '\n' - else: - lines = [] - for line in self._lines: - if line.startswith('#'): - continue - - line = line.rstrip() - lines.append(line) - - script = '&& '.join(lines) - return script - - def escape_string(self, value): - value = EscapedString.promote(value) - value = value.expanduser() - result = '' - - for is_literal, txt in value.strings: - if is_literal: - txt = self._escape_quotes(self._escape_vars(txt)) - else: - txt = self._escape_quotes(txt) - result += txt - return result - - def _saferefenv(self, key): - pass - - def shebang(self): - pass - - def setenv(self, key, value): - value = self.escape_string(value) - self._addline('$Env:{0} = "{1}"'.format(key, value)) - - def appendenv(self, key, value): - value = self.escape_string(value) - # Be careful about ambiguous case in pwsh on Linux where pathsep is : - # so that the ${ENV:VAR} form has to be used to not collide. - self._addline( - '$Env:{0} = "${{Env:{0}}}{1}{2}"'.format(key, os.path.pathsep, value) - ) - - def unsetenv(self, key): - self._addline(r"Remove-Item Env:\%s" % key) - - def resetenv(self, key, value, friends=None): - self._addline(self.setenv(key, value)) - - def alias(self, key, value): - value = EscapedString.disallow(value) - # TODO: Find a way to properly escape paths in alias() calls that also - # contain args - cmd = "function {key}() {{ {value} $args }}" - self._addline(cmd.format(key=key, value=value)) - - def comment(self, value): - for line in value.split('\n'): - self._addline('# %s' % line) - - def info(self, value): - for line in value.split('\n'): - line = self.escape_string(line) - line = self.convert_tokens(line) - self._addline('Write-Host %s' % line) - - def error(self, value): - for line in value.split('\n'): - line = self.escape_string(line) - line = self.convert_tokens(line) - self._addline('Write-Error "%s"' % line) - - def source(self, value): - self._addline(". \"%s\"" % value) - - def command(self, value): - self._addline(value) - - @classmethod - def get_all_key_tokens(cls, key): - return ["${Env:%s}" % key, "$Env:%s" % key] - - @classmethod - def join(cls, command): - # TODO: This may disappear in future [1] - # [1] https://bugs.python.org/issue10838 - return list2cmdline(command) - - @classmethod - def line_terminator(cls): - return "\n" +from .powershell_common.powershell_base import PowerShellBase class PowerShell(PowerShellBase): diff --git a/src/rezplugins/shell/powershell_common/__init__.py b/src/rezplugins/shell/powershell_common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/rezplugins/shell/powershell_common/powershell_base.py b/src/rezplugins/shell/powershell_common/powershell_base.py new file mode 100644 index 000000000..a54b5a83f --- /dev/null +++ b/src/rezplugins/shell/powershell_common/powershell_base.py @@ -0,0 +1,317 @@ +import os +import re +from subprocess import PIPE, list2cmdline + +from rez.backport.shutilwhich import which +from rez.config import config +from rez.rex import RexExecutor, OutputStyle, EscapedString +from rez.shells import Shell +from rez.utils.platform_ import platform_ +from rez.utils.system import popen + + +class PowerShellBase(Shell): + """ + Abstract base class for Powershell-like shells. + """ + expand_env_vars = True + + syspaths = None + _executable = None + + # Make sure that the $Env:VAR formats come before the $VAR formats since + # Powershell Environment variables are ambiguous with Unix paths. + ENV_VAR_REGEX = re.compile( + "|".join([ + "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR + "\\${[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)}", # ${Env:ENVVAR} + Shell.ENV_VAR_REGEX.pattern, # Generic form + ]) + ) + + @staticmethod + def _escape_quotes(s): + return s.replace('"', '`"').replace("'", "`'") + + @staticmethod + def _escape_vars(s): + return s.replace('$', '`$') + + @classmethod + def startup_capabilities(cls, + rcfile=False, + norc=False, + stdin=False, + command=False): + cls._unsupported_option('rcfile', rcfile) + cls._unsupported_option('norc', norc) + cls._unsupported_option('stdin', stdin) + rcfile = False + norc = False + stdin = False + return (rcfile, norc, stdin, command) + + @classmethod + def get_startup_sequence(cls, rcfile, norc, stdin, command): + rcfile, norc, stdin, command = \ + cls.startup_capabilities(rcfile, norc, stdin, command) + + return dict(stdin=stdin, + command=command, + do_rcfile=False, + envvar=None, + files=[], + bind_files=[], + source_bind_files=(not norc)) + + @classmethod + def get_syspaths(cls): + if cls.syspaths is not None: + return cls.syspaths + + if config.standard_system_paths: + cls.syspaths = config.standard_system_paths + return cls.syspaths + + # detect system paths using registry + def gen_expected_regex(parts): + whitespace = r"[\s]+" + return whitespace.join(parts) + + # TODO: Research if there is an easier way to pull system PATH from + # registry in powershell + paths = [] + + cmd = [ + "REG", "QUERY", + ("HKLM\\SYSTEM\\CurrentControlSet\\" + "Control\\Session Manager\\Environment"), "/v", "PATH" + ] + + expected = gen_expected_regex([ + ("HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\CurrentControlSet\\\\" + "Control\\\\Session Manager\\\\Environment"), "PATH", + "REG_(EXPAND_)?SZ", "(.*)" + ]) + + p = popen(cmd, + stdout=PIPE, + stderr=PIPE, + universal_newlines=True, + shell=True) + out_, _ = p.communicate() + out_ = out_.strip() + + if p.returncode == 0: + match = re.match(expected, out_) + if match: + paths.extend(match.group(2).split(os.pathsep)) + + cmd = ["REG", "QUERY", "HKCU\\Environment", "/v", "PATH"] + + expected = gen_expected_regex([ + "HKEY_CURRENT_USER\\\\Environment", "PATH", "REG_(EXPAND_)?SZ", + "(.*)" + ]) + + p = popen(cmd, + stdout=PIPE, + stderr=PIPE, + universal_newlines=True, + shell=True) + out_, _ = p.communicate() + out_ = out_.strip() + + if p.returncode == 0: + match = re.match(expected, out_) + if match: + paths.extend(match.group(2).split(os.pathsep)) + + cls.syspaths = list(set([x for x in paths if x])) + + # add Rez binaries + exe = which("rez-env") + assert exe, "Could not find rez binary, this is a bug" + rez_bin_dir = os.path.dirname(exe) + cls.syspaths.insert(0, rez_bin_dir) + + return cls.syspaths + + def _bind_interactive_rez(self): + if config.set_prompt and self.settings.prompt: + self._addline('Function prompt {"%s"}' % self.settings.prompt) + + def _additional_commands(self, executor): + # Make .py launch within cmd without extension. + # For PowerShell this will also execute in the same window, so that + # stdout can be captured. + if platform_.name == "windows" and self.settings.additional_pathext: + executor.command('$Env:PATHEXT = $Env:PATHEXT + ";{}"'.format( + ";".join(self.settings.additional_pathext) + )) + + def spawn_shell(self, + context_file, + tmpdir, + rcfile=None, + norc=False, + stdin=False, + command=None, + env=None, + quiet=False, + pre_command=None, + **Popen_args): + + startup_sequence = self.get_startup_sequence(rcfile, norc, bool(stdin), + command) + shell_command = None + + def _record_shell(ex, files, bind_rez=True, print_msg=False): + ex.source(context_file) + if startup_sequence["envvar"]: + ex.unsetenv(startup_sequence["envvar"]) + if bind_rez: + ex.interpreter._bind_interactive_rez() + if print_msg and not quiet: + # Rez may not be available + ex.command("Try { rez context } Catch { }") + + executor = RexExecutor(interpreter=self.new_shell(), + parent_environ={}, + add_default_namespaces=False) + + if startup_sequence["command"] is not None: + _record_shell(executor, files=startup_sequence["files"]) + shell_command = startup_sequence["command"] + else: + _record_shell(executor, + files=startup_sequence["files"], + print_msg=(not quiet)) + + self._additional_commands(executor) + + if shell_command: + executor.command(shell_command) + + # Forward exit call to parent PowerShell process + executor.command("exit $LastExitCode") + + code = executor.get_output() + target_file = os.path.join(tmpdir, + "rez-shell.%s" % self.file_extension()) + + with open(target_file, 'w') as f: + f.write(code) + + cmd = [] + if pre_command: + cmd = pre_command + + if not isinstance(cmd, (tuple, list)): + cmd = pre_command.rstrip().split() + + cmd += [self.executable] + + # Generic form of sourcing that works in powershell and pwsh + cmd += ['-File', '{}'.format(target_file)] + + if shell_command is None: + cmd.insert(1, "-noexit") + + p = popen(cmd, env=env, universal_newlines=True, **Popen_args) + return p + + def get_output(self, style=OutputStyle.file): + if style == OutputStyle.file: + script = '\n'.join(self._lines) + '\n' + else: + lines = [] + for line in self._lines: + if line.startswith('#'): + continue + + line = line.rstrip() + lines.append(line) + + script = '&& '.join(lines) + return script + + def escape_string(self, value): + value = EscapedString.promote(value) + value = value.expanduser() + result = '' + + for is_literal, txt in value.strings: + if is_literal: + txt = self._escape_quotes(self._escape_vars(txt)) + else: + txt = self._escape_quotes(txt) + result += txt + return result + + def _saferefenv(self, key): + pass + + def shebang(self): + pass + + def setenv(self, key, value): + value = self.escape_string(value) + self._addline('$Env:{0} = "{1}"'.format(key, value)) + + def appendenv(self, key, value): + value = self.escape_string(value) + # Be careful about ambiguous case in pwsh on Linux where pathsep is : + # so that the ${ENV:VAR} form has to be used to not collide. + self._addline( + '$Env:{0} = "${{Env:{0}}}{1}{2}"'.format(key, os.path.pathsep, value) + ) + + def unsetenv(self, key): + self._addline(r"Remove-Item Env:\%s" % key) + + def resetenv(self, key, value, friends=None): + self._addline(self.setenv(key, value)) + + def alias(self, key, value): + value = EscapedString.disallow(value) + # TODO: Find a way to properly escape paths in alias() calls that also + # contain args + cmd = "function {key}() {{ {value} $args }}" + self._addline(cmd.format(key=key, value=value)) + + def comment(self, value): + for line in value.split('\n'): + self._addline('# %s' % line) + + def info(self, value): + for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) + self._addline('Write-Host %s' % line) + + def error(self, value): + for line in value.split('\n'): + line = self.escape_string(line) + line = self.convert_tokens(line) + self._addline('Write-Error "%s"' % line) + + def source(self, value): + self._addline(". \"%s\"" % value) + + def command(self, value): + self._addline(value) + + @classmethod + def get_all_key_tokens(cls, key): + return ["${Env:%s}" % key, "$Env:%s" % key] + + @classmethod + def join(cls, command): + # TODO: This may disappear in future [1] + # [1] https://bugs.python.org/issue10838 + return list2cmdline(command) + + @classmethod + def line_terminator(cls): + return "\n" diff --git a/src/rezplugins/shell/pwsh.py b/src/rezplugins/shell/pwsh.py index 6f809121b..952210cff 100644 --- a/src/rezplugins/shell/pwsh.py +++ b/src/rezplugins/shell/pwsh.py @@ -2,7 +2,7 @@ from rez.shells import Shell from rez.utils.platform_ import platform_ -from rezplugins.shell.powershell import PowerShellBase +from .powershell_common.powershell_base import PowerShellBase class PowerShellCore(PowerShellBase): From 86c8026b6a900d287692dc9658244ec389161bb5 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Thu, 29 Aug 2019 15:01:27 -0400 Subject: [PATCH 13/17] Spelling. --- src/rez/rex.py | 2 +- src/rez/shells.py | 5 ++--- src/rez/tests/test_shells.py | 2 +- src/rez/util.py | 2 +- src/rezplugins/shell/powershell_common/powershell_base.py | 6 +++--- src/rezplugins/shell/pwsh.py | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/rez/rex.py b/src/rez/rex.py index fb4c80721..7e18c1846 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -447,7 +447,7 @@ class ActionInterpreter(object): expand_env_vars = False # RegEx that captures environment variables (generic form). - # Extend/Override to regex formats that can captured environment formats + # Extend/override to regex formats that can capture environment formats # in other interpreters like shells if needed ENV_VAR_REGEX = re.compile( "|".join([ diff --git a/src/rez/shells.py b/src/rez/shells.py index 32294320b..b1e2b0d4f 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -77,9 +77,8 @@ def _addline(self, line): def convert_tokens(self, value): """ - Converts any token form like ${VAR} and $VAR to shell specific - form. Uses the ENV_VAR_REGEX class variable to correctly parse - variables. + Converts any token like ${VAR} and $VAR to shell specific form. + Uses the ENV_VAR_REGEX to correctly parse tokens. Args: value: str to convert diff --git a/src/rez/tests/test_shells.py b/src/rez/tests/test_shells.py index 5b6d7efb0..ed1af3bbc 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -224,7 +224,7 @@ def _execute_code(func, expected_output): out, _ = p.communicate() self.assertEqual(p.returncode, 0) - # Powershell and Unix uses \n + # PowerShell and Unix uses \n sh = create_shell() output = out.strip().split(sh.line_terminator()) diff --git a/src/rez/util.py b/src/rez/util.py index e0ef31d10..7993e2254 100644 --- a/src/rez/util.py +++ b/src/rez/util.py @@ -88,7 +88,7 @@ def create_executable_script(filepath, body, program=None, py_script_mode=None): # default python, or in case of later python versions 'py' that should # try to use sensible python interpreters depending on the shebang line. # Compare PEP-397. - # In order to execution to work from windows we need to create a .py + # In order for execution to work in windows we need to create a .py # file and set the PATHEXT to include .py (as done by the shell plugins) # So depending on the py_script_mode we might need to create more then # one script diff --git a/src/rezplugins/shell/powershell_common/powershell_base.py b/src/rezplugins/shell/powershell_common/powershell_base.py index a54b5a83f..349a9df8f 100644 --- a/src/rezplugins/shell/powershell_common/powershell_base.py +++ b/src/rezplugins/shell/powershell_common/powershell_base.py @@ -12,7 +12,7 @@ class PowerShellBase(Shell): """ - Abstract base class for Powershell-like shells. + Abstract base class for PowerShell-like shells. """ expand_env_vars = True @@ -20,7 +20,7 @@ class PowerShellBase(Shell): _executable = None # Make sure that the $Env:VAR formats come before the $VAR formats since - # Powershell Environment variables are ambiguous with Unix paths. + # PowerShell Environment variables are ambiguous with Unix paths. ENV_VAR_REGEX = re.compile( "|".join([ "\\$[Ee][Nn][Vv]:([a-zA-Z_]+[a-zA-Z0-9_]*?)", # $Env:ENVVAR @@ -142,7 +142,7 @@ def _bind_interactive_rez(self): self._addline('Function prompt {"%s"}' % self.settings.prompt) def _additional_commands(self, executor): - # Make .py launch within cmd without extension. + # Make .py launch within shell without extension. # For PowerShell this will also execute in the same window, so that # stdout can be captured. if platform_.name == "windows" and self.settings.additional_pathext: diff --git a/src/rezplugins/shell/pwsh.py b/src/rezplugins/shell/pwsh.py index 952210cff..600ea3dd6 100644 --- a/src/rezplugins/shell/pwsh.py +++ b/src/rezplugins/shell/pwsh.py @@ -28,7 +28,7 @@ def get_syspaths(cls): else: # TODO: Newer versions of pwsh will parse .profile via sh [1], so # we could use a similar technique as SH itself. For now, to - # support older pwsh version we depend on SH on unix like platforms + # support older pwsh version we depend on SH on Unix-like platforms # directly. # [1] https://github.com/PowerShell/PowerShell/pull/10050 from rezplugins.shell.sh import SH From 9c4b967e5151d9da9e66c44d46103469f524f440 Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Wed, 4 Sep 2019 17:23:36 -0400 Subject: [PATCH 14/17] Fixes prompt now that literals and expansion work properly. --- src/rezplugins/shell/cmd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 97bc3d105..4e84ceeb0 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -2,7 +2,7 @@ Windows Command Prompt (DOS) shell. """ from rez.config import config -from rez.rex import RexExecutor, literal, OutputStyle, EscapedString +from rez.rex import RexExecutor, expandable, literal, OutputStyle, EscapedString from rez.shells import Shell from rez.system import system from rez.utils.system import popen @@ -184,9 +184,9 @@ def _create_ex(): executor = _create_ex() if self.settings.prompt: - newprompt = '%%REZ_ENV_PROMPT%%%s' % self.settings.prompt executor.interpreter._saferefenv('REZ_ENV_PROMPT') - executor.env.REZ_ENV_PROMPT = literal(newprompt) + executor.env.REZ_ENV_PROMPT = \ + expandable("%REZ_ENV_PROMPT%").literal(self.settings.prompt) # Make .py launch within cmd without extension. if self.settings.additional_pathext: From 9ef5e16dbd4b283a7e1b74d12ee42b18d458a9ce Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Wed, 4 Sep 2019 17:24:34 -0400 Subject: [PATCH 15/17] Supress Powershell header. Also fixes spelling of attribute to conform to PowerShell style. --- src/rezplugins/shell/powershell_common/powershell_base.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/rezplugins/shell/powershell_common/powershell_base.py b/src/rezplugins/shell/powershell_common/powershell_base.py index 349a9df8f..42a0466fc 100644 --- a/src/rezplugins/shell/powershell_common/powershell_base.py +++ b/src/rezplugins/shell/powershell_common/powershell_base.py @@ -210,13 +210,14 @@ def _record_shell(ex, files, bind_rez=True, print_msg=False): if not isinstance(cmd, (tuple, list)): cmd = pre_command.rstrip().split() - cmd += [self.executable] + # Suppresses copyright message of PowerShell and pwsh + cmd += [self.executable, '-NoLogo'] # Generic form of sourcing that works in powershell and pwsh cmd += ['-File', '{}'.format(target_file)] if shell_command is None: - cmd.insert(1, "-noexit") + cmd.insert(1, "-NoExit") p = popen(cmd, env=env, universal_newlines=True, **Popen_args) return p From a3f21db6f308d0130782960a2657dabf3f322d3c Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Wed, 4 Sep 2019 17:26:53 -0400 Subject: [PATCH 16/17] Ensures that no duplicates are added to PATHEXT. --- src/rezplugins/shell/cmd.py | 10 +++++++--- .../shell/powershell_common/powershell_base.py | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/rezplugins/shell/cmd.py b/src/rezplugins/shell/cmd.py index 4e84ceeb0..91259ba8b 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -190,9 +190,13 @@ def _create_ex(): # Make .py launch within cmd without extension. if self.settings.additional_pathext: - executor.command('set PATHEXT=%PATHEXT%;{}'.format( - ";".join(self.settings.additional_pathext) - )) + # Ensure that the PATHEXT does not append duplicates. + for pathext in self.settings.additional_pathext: + executor.command('echo %PATHEXT%|C:\\Windows\\System32\\findstr.exe /i /c:"{0}">nul || set PATHEXT=%PATHEXT%;{0}'.format( + pathext + )) + # This resets the errorcode, which is tainted by the code above + executor.command("(call )") if startup_sequence["command"] is not None: _record_shell(executor, files=startup_sequence["files"]) diff --git a/src/rezplugins/shell/powershell_common/powershell_base.py b/src/rezplugins/shell/powershell_common/powershell_base.py index 42a0466fc..e1db5a79b 100644 --- a/src/rezplugins/shell/powershell_common/powershell_base.py +++ b/src/rezplugins/shell/powershell_common/powershell_base.py @@ -146,7 +146,9 @@ def _additional_commands(self, executor): # For PowerShell this will also execute in the same window, so that # stdout can be captured. if platform_.name == "windows" and self.settings.additional_pathext: - executor.command('$Env:PATHEXT = $Env:PATHEXT + ";{}"'.format( + # Ensures that the PATHEXT does not append duplicates. + executor.command( + '$Env:PATHEXT = ((($Env:PATHEXT + ";{}") -split ";") | Select-Object -Unique) -join ";"'.format( ";".join(self.settings.additional_pathext) )) From e2f424203b8119cfac3056ed643a7064b022c3ec Mon Sep 17 00:00:00 2001 From: Blazej Floch Date: Fri, 6 Sep 2019 11:00:24 -0400 Subject: [PATCH 17/17] Fixes the exception for some IDEs like PyCharm. --- src/rez/tests/util.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/rez/tests/util.py b/src/rez/tests/util.py index 7f9bd455a..9f4cbf536 100644 --- a/src/rez/tests/util.py +++ b/src/rez/tests/util.py @@ -178,10 +178,12 @@ def wrapper(self, *args, **kwargs): try: func(self, *args, **kwargs) except AssertionError as e: - # Add the shell to the exception message - args = list(e.args) - args[0] += " (in shell '{}')".format(shell) - e.args = tuple(args) + # Add the shell to the exception message, if possible. + # In some IDEs the args do not exist at all. + if e.args: + args = list(e.args) + args[0] += " (in shell '{}')".format(shell) + e.args = tuple(args) raise return wrapper return decorator