diff --git a/src/rez/bind/hello_world.py b/src/rez/bind/hello_world.py index cd59d8fbf5..cb100a2652 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 c91334d26c..f0a2f67626 100644 --- a/src/rez/config.py +++ b/src/rez/config.py @@ -234,6 +234,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) @@ -303,6 +310,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 69766a9416..29fc235ea8 100644 --- a/src/rez/rex.py +++ b/src/rez/rex.py @@ -443,6 +443,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. @@ -874,12 +885,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) @@ -891,7 +896,11 @@ def escape_envvar(matchobj): value = (x for x in matchobj.groups() if x is not None).next() 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... @@ -1251,7 +1260,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 3e055327d8..44af802b35 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 83db28bbab..73104b0184 100644 --- a/src/rez/shells.py +++ b/src/rez/shells.py @@ -14,6 +14,7 @@ import os import os.path import pipes +import re def get_shell_types(): @@ -38,7 +39,6 @@ def create_shell(shell=None, **kwargs): class Shell(ActionInterpreter): """Class representing a shell, such as bash or tcsh. """ - schema_dict = { "prompt": basestring} @@ -160,6 +160,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: @@ -171,6 +194,7 @@ def join(self, command): """ raise NotImplementedError + class UnixShell(Shell): """ A base class for common *nix shells, such as bash and tcsh. @@ -396,8 +420,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 419f63a4ad..48a5560224 100644 --- a/src/rez/tests/test_shells.py +++ b/src/rez/tests/test_shells.py @@ -6,19 +6,17 @@ 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 import rez.vendor.unittest2 as 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_ import subprocess import tempfile import inspect import textwrap import os -import sys def _stdout(proc): @@ -47,15 +45,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: @@ -69,27 +62,20 @@ def test_no_output(self): "startup scripts are printing to stdout. Please remove the " "printout and try again.") - @shell_dependent(exclude=["cmd"]) + @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) @@ -145,13 +131,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 these platforms then if might + # be 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 @@ -189,13 +181,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 @@ -203,7 +200,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" @@ -225,12 +225,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') @@ -238,6 +240,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", @@ -268,6 +281,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. @@ -276,15 +304,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", @@ -294,6 +322,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 b49e84e6c4..260e2c1f69 100644 --- a/src/rez/util.py +++ b/src/rez/util.py @@ -10,11 +10,35 @@ 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 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 @@ -25,19 +49,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) @@ -46,18 +82,70 @@ 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 + 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 4a9d07d436..7d77aec2d9 100644 --- a/src/rezplugins/shell/cmd.py +++ b/src/rezplugins/shell/cmd.py @@ -21,6 +21,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 @@ -182,6 +185,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"] @@ -247,9 +256,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 @@ -297,8 +316,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 3d7f92f823..725c109c80 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, @@ -177,6 +192,14 @@ def _record_shell(ex, files, bind_rez=True, print_msg=False): files=startup_sequence["files"], print_msg=(not quiet)) + # Make .py launch within cmd without extension. + # For PowerShell this will also execute in the same window, so that + # stdout can be captured. + if self.settings.additional_pathext: + executor.command('$Env:PATHEXT = $Env:PATHEXT + ";{}"'.format( + ";".join(self.settings.additional_pathext) + )) + if shell_command: executor.command(shell_command) @@ -198,7 +221,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 +247,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 +267,11 @@ 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) + self._addline('$Env:{0} = "$Env:{0};{1}"'.format(key, value)) def unsetenv(self, key): self._addline(r"Remove-Item Env:\%s" % key) @@ -276,13 +304,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 3641d8ce1b..542b7aa627 100644 --- a/src/rezplugins/shell/pwsh.py +++ b/src/rezplugins/shell/pwsh.py @@ -6,20 +6,14 @@ from rez.utils.system import popen from rez.utils.platform_ import platform_ from rez.backport.shutilwhich import which +from rezplugins.shell.powershell import PowerShellBase from functools import partial from subprocess import PIPE import os import re -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 +29,10 @@ 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' - 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) - 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 8e40dc6107..271050eef9 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']