1# -*- coding: utf-8 -*- # 2# Copyright 2015 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16"""Utilities for configuring platform specific installation.""" 17 18from __future__ import absolute_import 19from __future__ import division 20from __future__ import print_function 21from __future__ import unicode_literals 22 23import os 24import re 25import shutil 26import sys 27 28from googlecloudsdk.core import properties 29from googlecloudsdk.core.console import console_io 30from googlecloudsdk.core.util import encoding 31from googlecloudsdk.core.util import files 32from googlecloudsdk.core.util import platforms 33 34import six 35 36_DEFAULT_SHELL = 'bash' 37# Shells supported by this module. 38_SUPPORTED_SHELLS = [_DEFAULT_SHELL, 'zsh', 'ksh', 'fish'] 39# Map of *.{shell}.inc compatible shells. e.g. ksh can source *.bash.inc. 40_COMPATIBLE_INC_SHELL = {'ksh': 'bash'} 41 42 43def _TraceAction(action): 44 """Prints action to standard error.""" 45 print(action, file=sys.stderr) 46 47 48# pylint:disable=unused-argument 49def _UpdatePathForWindows(bin_path): 50 """Update the Windows system path to include bin_path. 51 52 Args: 53 bin_path: str, The absolute path to the directory that will contain 54 Cloud SDK binaries. 55 """ 56 57 # pylint:disable=g-import-not-at-top, we want to only attempt these imports 58 # on windows. 59 try: 60 import win32con 61 import win32gui 62 from six.moves import winreg 63 except ImportError: 64 _TraceAction("""\ 65The installer is unable to automatically update your system PATH. Please add 66 {path} 67to your system PATH to enable easy use of the Cloud SDK Command Line Tools. 68""".format(path=bin_path)) 69 return 70 71 def GetEnv(name): 72 root = winreg.HKEY_CURRENT_USER 73 subkey = 'Environment' 74 key = winreg.OpenKey(root, subkey, 0, winreg.KEY_READ) 75 try: 76 value, _ = winreg.QueryValueEx(key, name) 77 # pylint:disable=undefined-variable, This variable is defined in windows. 78 except WindowsError: 79 return '' 80 return value 81 82 def SetEnv(name, value): 83 key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, 84 winreg.KEY_ALL_ACCESS) 85 winreg.SetValueEx(key, name, 0, winreg.REG_EXPAND_SZ, value) 86 winreg.CloseKey(key) 87 win32gui.SendMessage( 88 win32con.HWND_BROADCAST, win32con.WM_SETTINGCHANGE, 0, 'Environment') 89 return value 90 91 def Remove(paths, value): 92 while value in paths: 93 paths.remove(value) 94 95 def PrependEnv(name, values): 96 paths = GetEnv(name).split(';') 97 for value in values: 98 if value in paths: 99 Remove(paths, value) 100 paths.insert(0, value) 101 SetEnv(name, ';'.join(paths)) 102 103 PrependEnv('Path', [bin_path]) 104 105 _TraceAction("""\ 106The following directory has been added to your PATH. 107 {bin_path} 108 109Create a new command shell for the changes to take effect. 110""".format(bin_path=bin_path)) 111 112 113_INJECT_SH = """ 114{comment} 115if [ -f '{rc_path}' ]; then . '{rc_path}'; fi 116""" 117 118 119_INJECT_FISH = """ 120{comment} 121if [ -f '{rc_path}' ]; . '{rc_path}'; end 122""" 123 124 125def _GetRcContents(comment, rc_path, rc_contents, shell, pattern=None): 126 """Generates the RC file contents with new comment and `source rc_path` lines. 127 128 Args: 129 comment: The shell comment string that precedes the source line. 130 rc_path: The path of the rc file to source. 131 rc_contents: The current contents. 132 shell: The shell base name, specific to this module. 133 pattern: A regex pattern that matches comment, None for exact match on 134 comment. 135 136 Returns: 137 The comment and `source rc_path` lines to be inserted into a shell rc file. 138 """ 139 if not pattern: 140 pattern = re.escape(comment) 141 # This pattern handles all three variants that we have injected in user RC 142 # files. All have the same sentinel comment line followed by: 143 # 1. a single 'source ...' line 144 # 2. a 3 line if-fi (a bug because this pattern was previously incorrect) 145 # 3. finally a single if-fi line. 146 # If you touch this code ONLY INJECT ONE LINE AFTER THE SENTINEL COMMENT LINE. 147 # 148 # At some point we can drop the alternate patterns and only search for the 149 # sentinel comment line and assume the next line is ours too (that was the 150 # original intent before the 3-line form was added). 151 subre = re.compile('\n' + pattern + '\n(' 152 "source '.*'" 153 '|' 154 'if .*; then\n source .*\nfi' 155 '|' 156 'if .*; then (\\.|source) .*; fi' 157 '|' 158 'if .*; (\\.|source) .*; end' 159 '|' 160 'if .*; if type source .*; end' 161 ')\n', re.MULTILINE) 162 # script checks that the rc_path currently exists before sourcing the file 163 inject = _INJECT_FISH if shell == 'fish' else _INJECT_SH 164 line = inject.format(comment=comment, rc_path=rc_path) 165 filtered_contents = subre.sub('', rc_contents) 166 rc_contents = '{filtered_contents}{line}'.format( 167 filtered_contents=filtered_contents, line=line) 168 return rc_contents 169 170 171class _RcUpdater(object): 172 """Updates the RC file completion and PATH code injection.""" 173 174 def __init__(self, completion_update, path_update, shell, rc_path, sdk_root): 175 self.completion_update = completion_update 176 self.path_update = path_update 177 self.rc_path = rc_path 178 compatible_shell = _COMPATIBLE_INC_SHELL.get(shell, shell) 179 self.completion = os.path.join( 180 sdk_root, 'completion.{shell}.inc'.format(shell=compatible_shell)) 181 self.path = os.path.join( 182 sdk_root, 'path.{shell}.inc'.format(shell=compatible_shell)) 183 self.shell = shell 184 185 def _CompletionExists(self): 186 return os.path.exists(self.completion) 187 188 def Update(self): 189 """Creates or updates the RC file.""" 190 if self.rc_path: 191 192 # Check whether RC file is a file and store its contents. 193 if os.path.isfile(self.rc_path): 194 rc_contents = files.ReadFileContents(self.rc_path) 195 original_rc_contents = rc_contents 196 elif os.path.exists(self.rc_path): 197 _TraceAction( 198 '[{rc_path}] exists and is not a file, so it cannot be updated.' 199 .format(rc_path=self.rc_path)) 200 return 201 else: 202 rc_contents = '' 203 original_rc_contents = '' 204 205 if self.path_update: 206 rc_contents = _GetRcContents( 207 '# The next line updates PATH for the Google Cloud SDK.', 208 self.path, rc_contents, self.shell) 209 210 # gcloud doesn't (yet?) support completion for Fish, so check whether the 211 # completion file exists 212 if self.completion_update and self._CompletionExists(): 213 rc_contents = _GetRcContents( 214 '# The next line enables shell command completion for gcloud.', 215 self.completion, rc_contents, self.shell, 216 pattern=('# The next line enables [a-z][a-z]*' 217 ' command completion for gcloud.')) 218 219 if rc_contents == original_rc_contents: 220 _TraceAction('No changes necessary for [{rc}].'.format(rc=self.rc_path)) 221 return 222 223 if os.path.exists(self.rc_path): 224 rc_backup = self.rc_path + '.backup' 225 _TraceAction('Backing up [{rc}] to [{backup}].'.format( 226 rc=self.rc_path, backup=rc_backup)) 227 shutil.copyfile(self.rc_path, rc_backup) 228 229 # Update rc file, creating it if it does not exist. 230 rc_dir = os.path.dirname(self.rc_path) 231 try: 232 files.MakeDir(rc_dir) 233 except (files.Error, IOError, OSError): 234 _TraceAction( 235 'Could not create directories for [{rc_path}], so it ' 236 'cannot be updated.'.format(rc_path=self.rc_path)) 237 return 238 239 try: 240 files.WriteFileContents(self.rc_path, rc_contents) 241 except (files.Error, IOError, OSError): 242 _TraceAction( 243 'Could not update [{rc_path}]. Ensure you have write access to ' 244 'this location.'.format(rc_path=self.rc_path)) 245 return 246 247 _TraceAction('[{rc_path}] has been updated.'.format(rc_path=self.rc_path)) 248 _TraceAction(console_io.FormatRequiredUserAction( 249 'Start a new shell for the changes to take effect.')) 250 251 screen_reader = properties.VALUES.accessibility.screen_reader.GetBool() 252 prefix = '' if screen_reader else '==> ' 253 254 if not self.completion_update and self._CompletionExists(): 255 _TraceAction(prefix + 256 'Source [{rc}] in your profile to enable shell command ' 257 'completion for gcloud.'.format(rc=self.completion)) 258 259 if not self.path_update: 260 _TraceAction(prefix + 261 'Source [{rc}] in your profile to add the Google Cloud SDK ' 262 'command line tools to your $PATH.'.format(rc=self.path)) 263 264 265def _GetPreferredShell(path, default=_DEFAULT_SHELL): 266 """Returns the preferred shell name based on the base file name in path. 267 268 Args: 269 path: str, The file path to check. 270 default: str, The default value to return if a preferred name cannot be 271 determined. 272 273 Returns: 274 The preferred user shell name or default if none can be determined. 275 """ 276 if not path: 277 return default 278 name = os.path.basename(path) 279 for shell in _SUPPORTED_SHELLS: 280 if shell in six.text_type(name): 281 return shell 282 return default 283 284 285def _GetShellRcFileName(shell, host_os): 286 """Returns the RC file name for shell and host_os. 287 288 Args: 289 shell: str, The shell base name. 290 host_os: str, The host os identification string. 291 292 Returns: 293 The shell RC file name, '.bashrc' by default. 294 """ 295 if shell == 'ksh': 296 return encoding.GetEncodedValue(os.environ, 'ENV', None) or '.kshrc' 297 elif shell == 'fish': 298 return os.path.join('.config', 'fish', 'config.fish') 299 elif shell != 'bash': 300 return '.{shell}rc'.format(shell=shell) 301 elif host_os == platforms.OperatingSystem.LINUX: 302 return '.bashrc' 303 elif host_os == platforms.OperatingSystem.MACOSX: 304 return '.bash_profile' 305 elif host_os == platforms.OperatingSystem.MSYS: 306 return '.profile' 307 return '.bashrc' 308 309 310def _GetAndUpdateRcPath(completion_update, path_update, rc_path, host_os): 311 """Returns an rc path based on the default rc path or user input. 312 313 Gets default rc path based on environment. If prompts are enabled, 314 allows user to update to preferred file path. Otherwise, prints a warning 315 that the default rc path will be updated. 316 317 Args: 318 completion_update: bool, Whether or not to do command completion. 319 path_update: bool, Whether or not to update PATH. 320 rc_path: str, the rc path given by the user, from --rc-path arg. 321 host_os: str, The host os identification string. 322 323 Returns: 324 str, A path to the rc file to update. 325 """ 326 # If we aren't updating the RC file for either completions or PATH, there's 327 # no point. 328 if not (completion_update or path_update): 329 return None 330 if rc_path: 331 return rc_path 332 # A first guess at user preferred shell. 333 preferred_shell = _GetPreferredShell( 334 encoding.GetEncodedValue(os.environ, 'SHELL', '/bin/sh')) 335 default_rc_path = os.path.join( 336 files.GetHomeDir(), _GetShellRcFileName(preferred_shell, host_os)) 337 # If in quiet mode, we'll use default path. 338 if not console_io.CanPrompt(): 339 _TraceAction('You specified that you wanted to update your rc file. The ' 340 'default file will be updated: [{rc_path}]' 341 .format(rc_path=default_rc_path)) 342 return default_rc_path 343 rc_path_update = console_io.PromptResponse(( 344 'The Google Cloud SDK installer will now prompt you to update an rc ' 345 'file to bring the Google Cloud CLIs into your environment.\n\n' 346 'Enter a path to an rc file to update, or leave blank to use ' 347 '[{rc_path}]: ').format(rc_path=default_rc_path)) 348 return (files.ExpandHomeDir(rc_path_update) if rc_path_update 349 else default_rc_path) 350 351 352def _GetRcUpdater(completion_update, path_update, rc_path, sdk_root, host_os): 353 """Returns an _RcUpdater object for the preferred user shell. 354 355 Args: 356 completion_update: bool, Whether or not to do command completion. 357 path_update: bool, Whether or not to update PATH. 358 rc_path: str, The path to the rc file to update. If None, ask. 359 sdk_root: str, The path to the Cloud SDK root. 360 host_os: str, The host os identification string. 361 362 Returns: 363 An _RcUpdater() object for the preferred user shell. 364 """ 365 rc_path = _GetAndUpdateRcPath(completion_update, path_update, rc_path, 366 host_os) 367 # Check the rc_path for a better hint at the user preferred shell. 368 preferred_shell = _GetPreferredShell( 369 rc_path, 370 default=_GetPreferredShell( 371 encoding.GetEncodedValue(os.environ, 'SHELL', '/bin/sh'))) 372 return _RcUpdater( 373 completion_update, path_update, preferred_shell, rc_path, sdk_root) 374 375 376_PATH_PROMPT = 'update your $PATH' 377_COMPLETION_PROMPT = 'enable shell command completion' 378 379 380def _PromptToUpdate(path_update, completion_update): 381 """Prompt the user to update path or command completion if unspecified. 382 383 Args: 384 path_update: bool, Value of the --update-path arg. 385 completion_update: bool, Value of the --command-completion arg. 386 387 Returns: 388 (path_update, completion_update) (bool, bool) Whether to update path and 389 enable completion, respectively, after prompting the user. 390 """ 391 # If both were specified, no need to prompt. 392 if path_update is not None and completion_update is not None: 393 return path_update, completion_update 394 395 # Ask the user only one question to see if they want to do any unspecified 396 # updates. 397 actions = [] 398 if path_update is None: 399 actions.append(_PATH_PROMPT) 400 if completion_update is None: 401 actions.append(_COMPLETION_PROMPT) 402 prompt = '\nModify profile to {}?'.format(' and '.join(actions)) 403 response = console_io.PromptContinue(prompt) 404 405 # Update unspecified values to equal user response. 406 path_update = response if path_update is None else path_update 407 completion_update = (response if completion_update is None 408 else completion_update) 409 410 return path_update, completion_update 411 412 413def UpdateRC(completion_update, path_update, rc_path, bin_path, sdk_root): 414 """Update the system path to include bin_path. 415 416 Args: 417 completion_update: bool, Whether or not to do command completion. From 418 --command-completion arg during install. If None, ask. 419 path_update: bool, Whether or not to update PATH. From --path-update arg 420 during install. If None, ask. 421 rc_path: str, The path to the rc file to update. From --rc-path during 422 install. If None, ask. 423 bin_path: str, The absolute path to the directory that will contain 424 Cloud SDK binaries. 425 sdk_root: str, The path to the Cloud SDK root. 426 """ 427 host_os = platforms.OperatingSystem.Current() 428 if host_os == platforms.OperatingSystem.WINDOWS: 429 if path_update is None: 430 path_update = console_io.PromptContinue( 431 prompt_string='Update %PATH% to include Cloud SDK binaries?') 432 if path_update: 433 _UpdatePathForWindows(bin_path) 434 return 435 436 if console_io.CanPrompt(): 437 path_update, completion_update = _PromptToUpdate(path_update, 438 completion_update) 439 elif rc_path and (path_update is None and completion_update is None): 440 # In quiet mode, if the user gave a path to the RC and didn't specify what 441 # updates are desired, assume both. 442 path_update = True 443 completion_update = True 444 _TraceAction('Profile will be modified to {} and {}.' 445 .format(_PATH_PROMPT, _COMPLETION_PROMPT)) 446 447 _GetRcUpdater( 448 completion_update, path_update, rc_path, sdk_root, host_os).Update() 449