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