1# -*- coding: utf-8 -*- 2"""Tools to help interface with foreign shells, such as Bash.""" 3import os 4import re 5import json 6import shlex 7import sys 8import tempfile 9import builtins 10import subprocess 11import warnings 12import functools 13import collections.abc as cabc 14 15from xonsh.lazyasd import lazyobject 16from xonsh.tools import to_bool, ensure_string 17from xonsh.platform import ON_WINDOWS, ON_CYGWIN, ON_MSYS 18 19 20COMMAND = """{seterrprevcmd} 21{prevcmd} 22echo __XONSH_ENV_BEG__ 23{envcmd} 24echo __XONSH_ENV_END__ 25echo __XONSH_ALIAS_BEG__ 26{aliascmd} 27echo __XONSH_ALIAS_END__ 28echo __XONSH_FUNCS_BEG__ 29{funcscmd} 30echo __XONSH_FUNCS_END__ 31{postcmd} 32{seterrpostcmd}""" 33 34DEFAULT_BASH_FUNCSCMD = r"""# get function names from declare 35declstr=$(declare -F) 36read -r -a decls <<< $declstr 37funcnames="" 38for((n=0;n<${#decls[@]};n++)); do 39 if (( $(($n % 3 )) == 2 )); then 40 # get every 3rd entry 41 funcnames="$funcnames ${decls[$n]}" 42 fi 43done 44 45# get functions locations: funcname lineno filename 46shopt -s extdebug 47namelocfilestr=$(declare -F $funcnames) 48shopt -u extdebug 49 50# print just names and files as JSON object 51read -r -a namelocfile <<< $namelocfilestr 52sep=" " 53namefile="{" 54while IFS='' read -r line || [[ -n "$line" ]]; do 55 name=${line%%"$sep"*} 56 locfile=${line#*"$sep"} 57 loc=${locfile%%"$sep"*} 58 file=${locfile#*"$sep"} 59 namefile="${namefile}\"${name}\":\"${file//\\/\\\\}\"," 60done <<< "$namelocfilestr" 61if [[ "{" == "${namefile}" ]]; then 62 namefile="${namefile}}" 63else 64 namefile="${namefile%?}}" 65fi 66echo $namefile""" 67 68DEFAULT_ZSH_FUNCSCMD = """# get function names 69autoload -U is-at-least # We'll need to version check zsh 70namefile="{" 71for name in ${(ok)functions}; do 72 # force zsh to load the func in order to get the filename, 73 # but use +X so that it isn't executed. 74 autoload +X $name || continue 75 loc=$(whence -v $name) 76 loc=${(z)loc} 77 if is-at-least 5.2; then 78 file=${loc[-1]} 79 else 80 file=${loc[7,-1]} 81 fi 82 namefile="${namefile}\\"${name}\\":\\"${(Q)file:A}\\"," 83done 84if [[ "{" == "${namefile}" ]]; then 85 namefile="${namefile}}" 86else 87 namefile="${namefile%?}}" 88fi 89echo ${namefile}""" 90 91 92# mapping of shell name aliases to keys in other lookup dictionaries. 93@lazyobject 94def CANON_SHELL_NAMES(): 95 return { 96 "bash": "bash", 97 "/bin/bash": "bash", 98 "zsh": "zsh", 99 "/bin/zsh": "zsh", 100 "/usr/bin/zsh": "zsh", 101 "cmd": "cmd", 102 "cmd.exe": "cmd", 103 } 104 105 106@lazyobject 107def DEFAULT_ENVCMDS(): 108 return {"bash": "env", "zsh": "env", "cmd": "set"} 109 110 111@lazyobject 112def DEFAULT_ALIASCMDS(): 113 return {"bash": "alias", "zsh": "alias -L", "cmd": ""} 114 115 116@lazyobject 117def DEFAULT_FUNCSCMDS(): 118 return {"bash": DEFAULT_BASH_FUNCSCMD, "zsh": DEFAULT_ZSH_FUNCSCMD, "cmd": ""} 119 120 121@lazyobject 122def DEFAULT_SOURCERS(): 123 return {"bash": "source", "zsh": "source", "cmd": "call"} 124 125 126@lazyobject 127def DEFAULT_TMPFILE_EXT(): 128 return {"bash": ".sh", "zsh": ".zsh", "cmd": ".bat"} 129 130 131@lazyobject 132def DEFAULT_RUNCMD(): 133 return {"bash": "-c", "zsh": "-c", "cmd": "/C"} 134 135 136@lazyobject 137def DEFAULT_SETERRPREVCMD(): 138 return {"bash": "set -e", "zsh": "set -e", "cmd": "@echo off"} 139 140 141@lazyobject 142def DEFAULT_SETERRPOSTCMD(): 143 return {"bash": "", "zsh": "", "cmd": "if errorlevel 1 exit 1"} 144 145 146@functools.lru_cache() 147def foreign_shell_data( 148 shell, 149 interactive=True, 150 login=False, 151 envcmd=None, 152 aliascmd=None, 153 extra_args=(), 154 currenv=None, 155 safe=True, 156 prevcmd="", 157 postcmd="", 158 funcscmd=None, 159 sourcer=None, 160 use_tmpfile=False, 161 tmpfile_ext=None, 162 runcmd=None, 163 seterrprevcmd=None, 164 seterrpostcmd=None, 165 show=False, 166 dryrun=False, 167): 168 """Extracts data from a foreign (non-xonsh) shells. Currently this gets 169 the environment, aliases, and functions but may be extended in the future. 170 171 Parameters 172 ---------- 173 shell : str 174 The name of the shell, such as 'bash' or '/bin/sh'. 175 interactive : bool, optional 176 Whether the shell should be run in interactive mode. 177 login : bool, optional 178 Whether the shell should be a login shell. 179 envcmd : str or None, optional 180 The command to generate environment output with. 181 aliascmd : str or None, optional 182 The command to generate alias output with. 183 extra_args : tuple of str, optional 184 Additional command line options to pass into the shell. 185 currenv : tuple of items or None, optional 186 Manual override for the current environment. 187 safe : bool, optional 188 Flag for whether or not to safely handle exceptions and other errors. 189 prevcmd : str, optional 190 A command to run in the shell before anything else, useful for 191 sourcing and other commands that may require environment recovery. 192 postcmd : str, optional 193 A command to run after everything else, useful for cleaning up any 194 damage that the prevcmd may have caused. 195 funcscmd : str or None, optional 196 This is a command or script that can be used to determine the names 197 and locations of any functions that are native to the foreign shell. 198 This command should print *only* a JSON object that maps 199 function names to the filenames where the functions are defined. 200 If this is None, then a default script will attempted to be looked 201 up based on the shell name. Callable wrappers for these functions 202 will be returned in the aliases dictionary. 203 sourcer : str or None, optional 204 How to source a foreign shell file for purposes of calling functions 205 in that shell. If this is None, a default value will attempt to be 206 looked up based on the shell name. 207 use_tmpfile : bool, optional 208 This specifies if the commands are written to a tmp file or just 209 parsed directly to the shell 210 tmpfile_ext : str or None, optional 211 If tmpfile is True this sets specifies the extension used. 212 runcmd : str or None, optional 213 Command line switches to use when running the script, such as 214 -c for Bash and /C for cmd.exe. 215 seterrprevcmd : str or None, optional 216 Command that enables exit-on-error for the shell that is run at the 217 start of the script. For example, this is "set -e" in Bash. To disable 218 exit-on-error behavior, simply pass in an empty string. 219 seterrpostcmd : str or None, optional 220 Command that enables exit-on-error for the shell that is run at the end 221 of the script. For example, this is "if errorlevel 1 exit 1" in 222 cmd.exe. To disable exit-on-error behavior, simply pass in an 223 empty string. 224 show : bool, optional 225 Whether or not to display the script that will be run. 226 dryrun : bool, optional 227 Whether or not to actually run and process the command. 228 229 230 Returns 231 ------- 232 env : dict 233 Dictionary of shell's environment. (None if the subproc command fails) 234 aliases : dict 235 Dictionary of shell's aliases, this includes foreign function 236 wrappers.(None if the subproc command fails) 237 """ 238 cmd = [shell] 239 cmd.extend(extra_args) # needs to come here for GNU long options 240 if interactive: 241 cmd.append("-i") 242 if login: 243 cmd.append("-l") 244 shkey = CANON_SHELL_NAMES[shell] 245 envcmd = DEFAULT_ENVCMDS.get(shkey, "env") if envcmd is None else envcmd 246 aliascmd = DEFAULT_ALIASCMDS.get(shkey, "alias") if aliascmd is None else aliascmd 247 funcscmd = DEFAULT_FUNCSCMDS.get(shkey, "echo {}") if funcscmd is None else funcscmd 248 tmpfile_ext = ( 249 DEFAULT_TMPFILE_EXT.get(shkey, "sh") if tmpfile_ext is None else tmpfile_ext 250 ) 251 runcmd = DEFAULT_RUNCMD.get(shkey, "-c") if runcmd is None else runcmd 252 seterrprevcmd = ( 253 DEFAULT_SETERRPREVCMD.get(shkey, "") if seterrprevcmd is None else seterrprevcmd 254 ) 255 seterrpostcmd = ( 256 DEFAULT_SETERRPOSTCMD.get(shkey, "") if seterrpostcmd is None else seterrpostcmd 257 ) 258 command = COMMAND.format( 259 envcmd=envcmd, 260 aliascmd=aliascmd, 261 prevcmd=prevcmd, 262 postcmd=postcmd, 263 funcscmd=funcscmd, 264 seterrprevcmd=seterrprevcmd, 265 seterrpostcmd=seterrpostcmd, 266 ).strip() 267 if show: 268 print(command) 269 if dryrun: 270 return None, None 271 cmd.append(runcmd) 272 if not use_tmpfile: 273 cmd.append(command) 274 else: 275 tmpfile = tempfile.NamedTemporaryFile(suffix=tmpfile_ext, delete=False) 276 tmpfile.write(command.encode("utf8")) 277 tmpfile.close() 278 cmd.append(tmpfile.name) 279 if currenv is None and hasattr(builtins, "__xonsh_env__"): 280 currenv = builtins.__xonsh_env__.detype() 281 elif currenv is not None: 282 currenv = dict(currenv) 283 try: 284 s = subprocess.check_output( 285 cmd, 286 stderr=subprocess.PIPE, 287 env=currenv, 288 # start new session to avoid hangs 289 # (doesn't work on Cygwin though) 290 start_new_session=((not ON_CYGWIN) and (not ON_MSYS)), 291 universal_newlines=True, 292 ) 293 except (subprocess.CalledProcessError, FileNotFoundError): 294 if not safe: 295 raise 296 return None, None 297 finally: 298 if use_tmpfile: 299 os.remove(tmpfile.name) 300 env = parse_env(s) 301 aliases = parse_aliases(s) 302 funcs = parse_funcs(s, shell=shell, sourcer=sourcer, extra_args=extra_args) 303 aliases.update(funcs) 304 return env, aliases 305 306 307@lazyobject 308def ENV_RE(): 309 return re.compile("__XONSH_ENV_BEG__\n(.*)" "__XONSH_ENV_END__", flags=re.DOTALL) 310 311 312@lazyobject 313def ENV_SPLIT_RE(): 314 return re.compile("^([^=]+)=([^=]*|[^\n]*)$", flags=re.DOTALL | re.MULTILINE) 315 316 317def parse_env(s): 318 """Parses the environment portion of string into a dict.""" 319 m = ENV_RE.search(s) 320 if m is None: 321 return {} 322 g1 = m.group(1) 323 g1 = g1[:-1] if g1.endswith("\n") else g1 324 env = dict(ENV_SPLIT_RE.findall(g1)) 325 return env 326 327 328@lazyobject 329def ALIAS_RE(): 330 return re.compile( 331 "__XONSH_ALIAS_BEG__\n(.*)" "__XONSH_ALIAS_END__", flags=re.DOTALL 332 ) 333 334 335def parse_aliases(s): 336 """Parses the aliases portion of string into a dict.""" 337 m = ALIAS_RE.search(s) 338 if m is None: 339 return {} 340 g1 = m.group(1) 341 items = [ 342 line.split("=", 1) 343 for line in g1.splitlines() 344 if line.startswith("alias ") and "=" in line 345 ] 346 aliases = {} 347 for key, value in items: 348 try: 349 key = key[6:] # lstrip 'alias ' 350 # undo bash's weird quoting of single quotes (sh_single_quote) 351 value = value.replace("'\\''", "'") 352 # strip one single quote at the start and end of value 353 if value[0] == "'" and value[-1] == "'": 354 value = value[1:-1] 355 value = shlex.split(value) 356 except ValueError as exc: 357 warnings.warn( 358 'could not parse alias "{0}": {1!r}'.format(key, exc), RuntimeWarning 359 ) 360 continue 361 aliases[key] = value 362 return aliases 363 364 365@lazyobject 366def FUNCS_RE(): 367 return re.compile( 368 "__XONSH_FUNCS_BEG__\n(.+)\n" "__XONSH_FUNCS_END__", flags=re.DOTALL 369 ) 370 371 372def parse_funcs(s, shell, sourcer=None, extra_args=()): 373 """Parses the funcs portion of a string into a dict of callable foreign 374 function wrappers. 375 """ 376 m = FUNCS_RE.search(s) 377 if m is None: 378 return {} 379 g1 = m.group(1) 380 if ON_WINDOWS: 381 g1 = g1.replace(os.sep, os.altsep) 382 try: 383 namefiles = json.loads(g1.strip()) 384 except json.decoder.JSONDecodeError as exc: 385 msg = ( 386 "{0!r}\n\ncould not parse {1} functions:\n" 387 " s = {2!r}\n" 388 " g1 = {3!r}\n\n" 389 "Note: you may be seeing this error if you use zsh with " 390 "prezto. Prezto overwrites GNU coreutils functions (like echo) " 391 "with its own zsh functions. Please try disabling prezto." 392 ) 393 warnings.warn(msg.format(exc, shell, s, g1), RuntimeWarning) 394 return {} 395 sourcer = DEFAULT_SOURCERS.get(shell, "source") if sourcer is None else sourcer 396 funcs = {} 397 for funcname, filename in namefiles.items(): 398 if funcname.startswith("_") or not filename: 399 continue # skip private functions and invalid files 400 if not os.path.isabs(filename): 401 filename = os.path.abspath(filename) 402 wrapper = ForeignShellFunctionAlias( 403 name=funcname, 404 shell=shell, 405 sourcer=sourcer, 406 filename=filename, 407 extra_args=extra_args, 408 ) 409 funcs[funcname] = wrapper 410 return funcs 411 412 413class ForeignShellFunctionAlias(object): 414 """This class is responsible for calling foreign shell functions as if 415 they were aliases. This does not currently support taking stdin. 416 """ 417 418 INPUT = '{sourcer} "{filename}"\n' "{funcname} {args}\n" 419 420 def __init__(self, name, shell, filename, sourcer=None, extra_args=()): 421 """ 422 Parameters 423 ---------- 424 name : str 425 function name 426 shell : str 427 Name or path to shell 428 filename : str 429 Where the function is defined, path to source. 430 sourcer : str or None, optional 431 Command to source foreign files with. 432 extra_args : tuple of str, optional 433 Additional command line options to pass into the shell. 434 """ 435 sourcer = DEFAULT_SOURCERS.get(shell, "source") if sourcer is None else sourcer 436 self.name = name 437 self.shell = shell 438 self.filename = filename 439 self.sourcer = sourcer 440 self.extra_args = extra_args 441 442 def __eq__(self, other): 443 if ( 444 not hasattr(other, "name") 445 or not hasattr(other, "shell") 446 or not hasattr(other, "filename") 447 or not hasattr(other, "sourcer") 448 or not hasattr(other, "exta_args") 449 ): 450 return NotImplemented 451 return ( 452 (self.name == other.name) 453 and (self.shell == other.shell) 454 and (self.filename == other.filename) 455 and (self.sourcer == other.sourcer) 456 and (self.extra_args == other.extra_args) 457 ) 458 459 def __call__(self, args, stdin=None): 460 args, streaming = self._is_streaming(args) 461 input = self.INPUT.format( 462 sourcer=self.sourcer, 463 filename=self.filename, 464 funcname=self.name, 465 args=" ".join(args), 466 ) 467 cmd = [self.shell] + list(self.extra_args) + ["-c", input] 468 env = builtins.__xonsh_env__ 469 denv = env.detype() 470 if streaming: 471 subprocess.check_call(cmd, env=denv) 472 out = None 473 else: 474 out = subprocess.check_output(cmd, env=denv, stderr=subprocess.STDOUT) 475 out = out.decode( 476 encoding=env.get("XONSH_ENCODING"), 477 errors=env.get("XONSH_ENCODING_ERRORS"), 478 ) 479 out = out.replace("\r\n", "\n") 480 return out 481 482 def _is_streaming(self, args): 483 """Test and modify args if --xonsh-stream is present.""" 484 if "--xonsh-stream" not in args: 485 return args, False 486 args = list(args) 487 args.remove("--xonsh-stream") 488 return args, True 489 490 491@lazyobject 492def VALID_SHELL_PARAMS(): 493 return frozenset( 494 [ 495 "shell", 496 "interactive", 497 "login", 498 "envcmd", 499 "aliascmd", 500 "extra_args", 501 "currenv", 502 "safe", 503 "prevcmd", 504 "postcmd", 505 "funcscmd", 506 "sourcer", 507 ] 508 ) 509 510 511def ensure_shell(shell): 512 """Ensures that a mapping follows the shell specification.""" 513 if not isinstance(shell, cabc.MutableMapping): 514 shell = dict(shell) 515 shell_keys = set(shell.keys()) 516 if not (shell_keys <= VALID_SHELL_PARAMS): 517 msg = "unknown shell keys: {0}" 518 raise KeyError(msg.format(shell_keys - VALID_SHELL_PARAMS)) 519 shell["shell"] = ensure_string(shell["shell"]).lower() 520 if "interactive" in shell_keys: 521 shell["interactive"] = to_bool(shell["interactive"]) 522 if "login" in shell_keys: 523 shell["login"] = to_bool(shell["login"]) 524 if "envcmd" in shell_keys: 525 shell["envcmd"] = ( 526 None if shell["envcmd"] is None else ensure_string(shell["envcmd"]) 527 ) 528 if "aliascmd" in shell_keys: 529 shell["aliascmd"] = ( 530 None if shell["aliascmd"] is None else ensure_string(shell["aliascmd"]) 531 ) 532 if "extra_args" in shell_keys and not isinstance(shell["extra_args"], tuple): 533 shell["extra_args"] = tuple(map(ensure_string, shell["extra_args"])) 534 if "currenv" in shell_keys and not isinstance(shell["currenv"], tuple): 535 ce = shell["currenv"] 536 if isinstance(ce, cabc.Mapping): 537 ce = tuple([(ensure_string(k), v) for k, v in ce.items()]) 538 elif isinstance(ce, cabc.Sequence): 539 ce = tuple([(ensure_string(k), v) for k, v in ce]) 540 else: 541 raise RuntimeError("unrecognized type for currenv") 542 shell["currenv"] = ce 543 if "safe" in shell_keys: 544 shell["safe"] = to_bool(shell["safe"]) 545 if "prevcmd" in shell_keys: 546 shell["prevcmd"] = ensure_string(shell["prevcmd"]) 547 if "postcmd" in shell_keys: 548 shell["postcmd"] = ensure_string(shell["postcmd"]) 549 if "funcscmd" in shell_keys: 550 shell["funcscmd"] = ( 551 None if shell["funcscmd"] is None else ensure_string(shell["funcscmd"]) 552 ) 553 if "sourcer" in shell_keys: 554 shell["sourcer"] = ( 555 None if shell["sourcer"] is None else ensure_string(shell["sourcer"]) 556 ) 557 if "seterrprevcmd" in shell_keys: 558 shell["seterrprevcmd"] = ( 559 None 560 if shell["seterrprevcmd"] is None 561 else ensure_string(shell["seterrprevcmd"]) 562 ) 563 if "seterrpostcmd" in shell_keys: 564 shell["seterrpostcmd"] = ( 565 None 566 if shell["seterrpostcmd"] is None 567 else ensure_string(shell["seterrpostcmd"]) 568 ) 569 return shell 570 571 572def load_foreign_envs(shells): 573 """Loads environments from foreign shells. 574 575 Parameters 576 ---------- 577 shells : sequence of dicts 578 An iterable of dicts that can be passed into foreign_shell_data() as 579 keyword arguments. 580 581 Returns 582 ------- 583 env : dict 584 A dictionary of the merged environments. 585 """ 586 env = {} 587 for shell in shells: 588 shell = ensure_shell(shell) 589 shenv, _ = foreign_shell_data(**shell) 590 if shenv: 591 env.update(shenv) 592 return env 593 594 595def load_foreign_aliases(shells): 596 """Loads aliases from foreign shells. 597 598 Parameters 599 ---------- 600 shells : sequence of dicts 601 An iterable of dicts that can be passed into foreign_shell_data() as 602 keyword arguments. 603 604 Returns 605 ------- 606 aliases : dict 607 A dictionary of the merged aliases. 608 """ 609 aliases = {} 610 xonsh_aliases = builtins.aliases 611 for shell in shells: 612 shell = ensure_shell(shell) 613 _, shaliases = foreign_shell_data(**shell) 614 if not builtins.__xonsh_env__.get("FOREIGN_ALIASES_OVERRIDE"): 615 shaliases = {} if shaliases is None else shaliases 616 for alias in set(shaliases) & set(xonsh_aliases): 617 del shaliases[alias] 618 if builtins.__xonsh_env__.get("XONSH_DEBUG") > 1: 619 print( 620 "aliases: ignoring alias {!r} of shell {!r} " 621 "which tries to override xonsh alias." 622 "".format(alias, shell["shell"]), 623 file=sys.stderr, 624 ) 625 aliases.update(shaliases) 626 return aliases 627