1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14# pylint: disable=line-too-long 15"""Pigweed shell activation script. 16 17Aside from importing it, this script can be used in three ways: 18 191. Activate the Pigweed environment in your current shell (i.e., modify your 20 interactive shell's environment with Pigweed environment variables). 21 22 Using bash (assuming a global Python 3 is in $PATH): 23 source <(python3 ./pw_ide/activate.py -s bash) 24 25 Using bash (using the environment Python): 26 source <({environment}/pigweed-venv/bin/python ./pw_ide/activate.py -s bash) 27 282. Run a shell command or executable in an activated shell (i.e. apply a 29 modified environment to a subprocess without affecting your current 30 interactive shell). 31 32 Example (assuming a global Python 3 is in $PATH): 33 python3 ./pw_ide/activate.py -x 'pw ide cpp --list' 34 35 Example (using the environment Python): 36 {environment}/pigweed-venv/bin/python ./pw_ide/activate.py -x 'pw ide cpp --list' 37 38 Example (using the environment Python on Windows): 39 {environment}/pigweed-venv/Scripts/pythonw.exe ./pw_ide/activate.py -x 'pw ide cpp --list' 40 413. Produce a JSON representation of the Pigweed activated environment (-O) or 42 the diff against your current environment that produces an activated 43 environment (-o). See the help for more detailed information on the options 44 available. 45 46 Example (assuming a global Python 3 is in $PATH): 47 python3 ./pw_ide/activate.py -o 48 49 Example (using the environment Python): 50 {environment}/pigweed-venv/bin/python ./pw_ide/activate.py -o 51 52 Example (using the environment Python on Windows): 53 {environment}/pigweed-venv/Scripts/pythonw.exe ./pw_ide/activate.py -o 54""" 55# pylint: enable=line-too-long 56 57from abc import abstractmethod, ABC 58import argparse 59from collections import defaultdict 60from inspect import cleandoc 61import json 62import os 63from pathlib import Path 64import shlex 65import subprocess 66import sys 67from typing import cast, Dict, Optional 68 69# This expects this file to be in the Python module. If it ever moves 70# (e.g. to the root of the repository), this will need to change. 71_PW_PROJECT_PATH = Path( 72 os.environ.get( 73 'PW_PROJECT_ROOT', os.environ.get('PW_ROOT', Path(__file__).parents[3]) 74 ) 75) 76 77 78def assumed_environment_root() -> Optional[Path]: 79 """Infer the path to the Pigweed environment directory. 80 81 First we look at the environment variable that should contain the path if 82 we're operating in an activated Pigweed environment. If we don't find the 83 path there, we check a few known locations. If we don't find an environment 84 directory in any of those locations, we return None. 85 """ 86 actual_environment_root = os.environ.get('_PW_ACTUAL_ENVIRONMENT_ROOT') 87 if ( 88 actual_environment_root is not None 89 and (root_path := Path(actual_environment_root)).exists() 90 ): 91 return root_path.absolute() 92 93 default_environment = _PW_PROJECT_PATH / 'environment' 94 if default_environment.exists(): 95 return default_environment.absolute() 96 97 default_dot_environment = _PW_PROJECT_PATH / '.environment' 98 if default_dot_environment.exists(): 99 return default_dot_environment.absolute() 100 101 return None 102 103 104# We're looking for the `actions.json` file that allows us to activate the 105# Pigweed environment. That file is located in the Pigweed environment 106# directory, so if we found an environment directory, this variable will 107# have the path to `actions.json`. If it doesn't find an environment directory 108# (e.g., this isn't being executed in the context of a Pigweed project), this 109# will be None. Note that this is the "default" config file path because 110# callers of functions that need this path can provide their own paths to an 111# `actions.json` file. 112_DEFAULT_CONFIG_FILE_PATH = ( 113 None 114 if assumed_environment_root() is None 115 else cast(Path, assumed_environment_root()) / 'actions.json' 116) 117 118 119def _sanitize_path( 120 path: str, project_root_prefix: str, user_home_prefix: str 121) -> str: 122 """Given a path, return a sanitized path. 123 124 By default, environment variable paths are usually absolute. If we want 125 those paths to work across multiple systems, we need to sanitize them. This 126 takes a string that may be a path, and if it is indeed a path, it returns 127 the sanitized path, which is relative to either the repository root or the 128 user's home directory. If it's not a path, it just returns the input. 129 130 You can provide the strings that should be substituted for the project root 131 and the user's home directory. This may be useful for applications that have 132 their own way of representing those directories. 133 134 Note that this is intended to work on Pigweed environment variables, which 135 should all be relative to either of those two locations. Paths that aren't 136 (e.g. the path to a system binary) won't really be sanitized. 137 """ 138 # Return the argument if it's not actually a path. 139 # This strategy relies on the fact that env_setup outputs absolute paths for 140 # all path env vars. So if we get a variable that's not an absolute path, it 141 # must not be a path at all. 142 if not Path(path).is_absolute(): 143 return path 144 145 project_root = _PW_PROJECT_PATH.resolve() 146 user_home = Path.home().resolve() 147 resolved_path = Path(path).resolve() 148 149 # TODO(b/248257406) Remove once we drop support for Python 3.8. 150 def is_relative_to(path: Path, other: Path) -> bool: 151 try: 152 path.relative_to(other) 153 return True 154 except ValueError: 155 return False 156 157 if is_relative_to(resolved_path, project_root): 158 return f'{project_root_prefix}/' + str( 159 resolved_path.relative_to(project_root) 160 ) 161 162 if is_relative_to(resolved_path, user_home): 163 return f'{user_home_prefix}/' + str( 164 resolved_path.relative_to(user_home) 165 ) 166 167 # Path is not in the project root or user home, so just return it as is. 168 return path 169 170 171class ShellModifier(ABC): 172 """Abstract class for shell modifiers. 173 174 A shell modifier provides an interface for modifying the environment 175 variables in various shells. You can pass in a current environment state 176 as a dictionary during instantiation and modify it and/or modify shell state 177 through other side effects. 178 """ 179 180 separator = ':' 181 comment = '# ' 182 183 def __init__( 184 self, 185 env: Optional[Dict[str, str]] = None, 186 env_only: bool = False, 187 path_var: str = '$PATH', 188 project_root: str = '.', 189 user_home: str = '~', 190 ): 191 # This will contain only the modifications to the environment, with 192 # no elements of the existing environment aside from variables included 193 # here. In that sense, it's like a diff against the existing 194 # environment, or a structured form of the shell modification side 195 # effects. 196 default_env_mod = {'PATH': path_var} 197 self.env_mod = default_env_mod.copy() 198 199 # This is seeded with the existing environment, and then is modified. 200 # So it contains the complete new environment after modifications. 201 # If no existing environment is provided, this is identical to env_mod. 202 env = env if env is not None else default_env_mod.copy() 203 self.env: Dict[str, str] = defaultdict(str, env) 204 205 # Will contain the side effects, i.e. commands executed in the shell to 206 # modify its environment. 207 self.side_effects = '' 208 209 # Set this to not do any side effects, but just modify the environment 210 # stored in this class. 211 self.env_only = env_only 212 213 self.project_root = project_root 214 self.user_home = user_home 215 216 def do_effect(self, effect: str): 217 """Add to the commands that will affect the shell's environment. 218 219 This is a no-op if the shell modifier is set to only store shell 220 modification data rather than doing the side effects. 221 """ 222 if not self.env_only: 223 self.side_effects += f'{effect}\n' 224 225 def modify_env( 226 self, 227 config_file_path: Optional[Path] = _DEFAULT_CONFIG_FILE_PATH, 228 sanitize: bool = False, 229 ) -> 'ShellModifier': 230 """Modify the current shell state per the actions.json file provided.""" 231 json_file_options = {} 232 233 if config_file_path is None: 234 raise RuntimeError( 235 'This must be run from a bootstrapped Pigweed directory!' 236 ) 237 238 with config_file_path.open('r') as json_file: 239 json_file_options = json.loads(json_file.read()) 240 241 root = self.project_root 242 home = self.user_home 243 244 # Set env vars 245 for var_name, value in json_file_options.get('set', dict()).items(): 246 if value is not None: 247 value = _sanitize_path(value, root, home) if sanitize else value 248 self.set_variable(var_name, value) 249 250 # Prepend & append env vars 251 for var_name, mode_changes in json_file_options.get( 252 'modify', dict() 253 ).items(): 254 for mode_name, values in mode_changes.items(): 255 if mode_name in ['prepend', 'append']: 256 modify_variable = self.prepend_variable 257 258 if mode_name == 'append': 259 modify_variable = self.append_variable 260 261 for value in values: 262 value = ( 263 _sanitize_path(value, root, home) 264 if sanitize 265 else value 266 ) 267 modify_variable(var_name, value) 268 269 return self 270 271 @abstractmethod 272 def set_variable(self, var_name: str, value: str) -> None: 273 pass 274 275 @abstractmethod 276 def prepend_variable(self, var_name: str, value: str) -> None: 277 pass 278 279 @abstractmethod 280 def append_variable(self, var_name: str, value: str) -> None: 281 pass 282 283 284class BashShellModifier(ShellModifier): 285 """Shell modifier for bash.""" 286 287 def set_variable(self, var_name: str, value: str): 288 self.env[var_name] = value 289 self.env_mod[var_name] = value 290 quoted_value = shlex.quote(value) 291 self.do_effect(f'export {var_name}={quoted_value}') 292 293 def prepend_variable(self, var_name: str, value: str) -> None: 294 self.env[var_name] = f'{value}{self.separator}{self.env[var_name]}' 295 self.env_mod[ 296 var_name 297 ] = f'{value}{self.separator}{self.env_mod[var_name]}' 298 quoted_value = shlex.quote(value) 299 self.do_effect( 300 f'export {var_name}={quoted_value}{self.separator}${var_name}' 301 ) 302 303 def append_variable(self, var_name: str, value: str) -> None: 304 self.env[var_name] = f'{self.env[var_name]}{self.separator}{value}' 305 self.env_mod[ 306 var_name 307 ] = f'{self.env_mod[var_name]}{self.separator}{value}' 308 quoted_value = shlex.quote(value) 309 self.do_effect( 310 f'export {var_name}=${var_name}{self.separator}{quoted_value}' 311 ) 312 313 314def _build_argument_parser() -> argparse.ArgumentParser: 315 """Set up `argparse`.""" 316 doc = __doc__ 317 318 try: 319 env_root = assumed_environment_root() 320 except RuntimeError: 321 env_root = None 322 323 # Substitute in the actual environment path in the help text, if we can 324 # find it. If not, leave the placeholder text. 325 if env_root is not None: 326 doc = doc.replace( 327 '{environment}', str(env_root.relative_to(Path.cwd())) 328 ) 329 330 parser = argparse.ArgumentParser( 331 formatter_class=argparse.RawDescriptionHelpFormatter, 332 description=doc, 333 ) 334 335 default_config_file_path = None 336 337 if _DEFAULT_CONFIG_FILE_PATH is not None: 338 default_config_file_path = _DEFAULT_CONFIG_FILE_PATH.relative_to( 339 Path.cwd() 340 ) 341 342 parser.add_argument( 343 '-c', 344 '--config-file', 345 default=_DEFAULT_CONFIG_FILE_PATH, 346 type=Path, 347 help='Path to actions.json config file, which defines ' 348 'the modifications to the shell environment ' 349 'needed to activate Pigweed. ' 350 f'Default: {default_config_file_path}', 351 ) 352 353 default_shell = Path(os.environ['SHELL']).name 354 parser.add_argument( 355 '-s', 356 '--shell-mode', 357 default=default_shell, 358 help='Which shell is being used. ' f'Default: {default_shell}', 359 ) 360 361 parser.add_argument( 362 '-o', 363 '--out', 364 action='store_true', 365 help='Write only the modifications to the environment ' 'out to JSON.', 366 ) 367 368 parser.add_argument( 369 '-O', 370 '--out-all', 371 action='store_true', 372 help='Write the complete modified environment to ' 'JSON.', 373 ) 374 375 parser.add_argument( 376 '-n', 377 '--sanitize', 378 action='store_true', 379 help='Sanitize paths that are relative to the repo ' 380 'root or user home directory so that they are portable ' 381 'to other workstations.', 382 ) 383 384 parser.add_argument( 385 '--path-var', 386 default='$PATH', 387 help='The string to substitute for the existing $PATH. Default: $PATH', 388 ) 389 390 parser.add_argument( 391 '--project-root', 392 default='.', 393 help='The string to substitute for the project root when sanitizing ' 394 'paths. Default: .', 395 ) 396 397 parser.add_argument( 398 '--user-home', 399 default='~', 400 help='The string to substitute for the user\'s home when sanitizing ' 401 'paths. Default: ~', 402 ) 403 404 parser.add_argument( 405 '-x', 406 '--exec', 407 help='A command to execute in the activated shell.', 408 metavar='COMMAND', 409 ) 410 411 return parser 412 413 414def main() -> int: 415 """The main CLI script.""" 416 args, _unused_extra_args = _build_argument_parser().parse_known_args() 417 env = os.environ.copy() 418 config_file_path = args.config_file 419 420 if not config_file_path.exists(): 421 sys.stderr.write(f'File not found! {config_file_path}') 422 sys.stderr.write( 423 'This must be run from a bootstrapped Pigweed ' 'project directory.' 424 ) 425 sys.exit(1) 426 427 # If we're executing a command in a subprocess, don't modify the current 428 # shell's state. Instead, apply the modified state to the subprocess. 429 env_only = args.exec is not None 430 431 # Assume bash by default. 432 shell_modifier = BashShellModifier 433 434 # TODO(chadnorvell): if args.shell_mode == 'zsh', 'ksh', 'fish'... 435 try: 436 modified_env = shell_modifier( 437 env=env, 438 env_only=env_only, 439 path_var=args.path_var, 440 project_root=args.project_root, 441 user_home=args.user_home, 442 ).modify_env(config_file_path, args.sanitize) 443 except (FileNotFoundError, json.JSONDecodeError): 444 sys.stderr.write( 445 'Unable to read file: {}\n' 446 'Please run this in bash or zsh:\n' 447 ' . ./bootstrap.sh\n'.format(str(config_file_path)) 448 ) 449 450 sys.exit(1) 451 452 if args.out_all: 453 print(json.dumps(modified_env.env, sort_keys=True, indent=2)) 454 return 0 455 456 if args.out: 457 print(json.dumps(modified_env.env_mod, sort_keys=True, indent=2)) 458 return 0 459 460 if args.exec is not None: 461 # We're executing a command in a subprocess with the modified env. 462 return subprocess.run( 463 args.exec, env=modified_env.env, shell=True 464 ).returncode 465 466 # If we got here, we're trying to modify the current shell's env. 467 print(modified_env.side_effects) 468 469 # Let's warn the user if the output is going to stdout instead of being 470 # executed by the shell. 471 python_path = Path(sys.executable).relative_to(os.getcwd()) 472 c = shell_modifier.comment # pylint: disable=invalid-name 473 print( 474 cleandoc( 475 f""" 476 {c} 477 {c}Can you see these commands? If so, you probably wanted to 478 {c}source this script instead of running it. Try this instead: 479 {c} 480 {c} . <({str(python_path)} {' '.join(sys.argv)}) 481 {c} 482 {c}Run this script with `-h` for more help.""" 483 ) 484 ) 485 return 0 486 487 488if __name__ == '__main__': 489 sys.exit(main()) 490