OLD | NEW |
| (Empty) |
1 # -*- coding: utf-8 -*- | |
2 # Copyright 2011 Google Inc. 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 """Class that runs a named gsutil command.""" | |
16 | |
17 from __future__ import absolute_import | |
18 | |
19 import difflib | |
20 import logging | |
21 import os | |
22 import pkgutil | |
23 import sys | |
24 import textwrap | |
25 import time | |
26 | |
27 import boto | |
28 from boto.storage_uri import BucketStorageUri | |
29 import gslib | |
30 from gslib.cloud_api_delegator import CloudApiDelegator | |
31 from gslib.command import Command | |
32 from gslib.command import CreateGsutilLogger | |
33 from gslib.command import GetFailureCount | |
34 from gslib.command import OLD_ALIAS_MAP | |
35 from gslib.command import ShutDownGsutil | |
36 import gslib.commands | |
37 from gslib.cs_api_map import ApiSelector | |
38 from gslib.cs_api_map import GsutilApiClassMapFactory | |
39 from gslib.cs_api_map import GsutilApiMapFactory | |
40 from gslib.exception import CommandException | |
41 from gslib.gcs_json_api import GcsJsonApi | |
42 from gslib.no_op_credentials import NoOpCredentials | |
43 from gslib.tab_complete import MakeCompleter | |
44 from gslib.util import CompareVersions | |
45 from gslib.util import GetGsutilVersionModifiedTime | |
46 from gslib.util import GSUTIL_PUB_TARBALL | |
47 from gslib.util import IsRunningInteractively | |
48 from gslib.util import LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE | |
49 from gslib.util import LookUpGsutilVersion | |
50 from gslib.util import MultiprocessingIsAvailable | |
51 from gslib.util import RELEASE_NOTES_URL | |
52 from gslib.util import SECONDS_PER_DAY | |
53 from gslib.util import UTF8 | |
54 | |
55 | |
56 def HandleArgCoding(args): | |
57 """Handles coding of command-line args. | |
58 | |
59 Args: | |
60 args: array of command-line args. | |
61 | |
62 Returns: | |
63 array of command-line args. | |
64 | |
65 Raises: | |
66 CommandException: if errors encountered. | |
67 """ | |
68 # Python passes arguments from the command line as byte strings. To | |
69 # correctly interpret them, we decode ones other than -h and -p args (which | |
70 # will be passed as headers, and thus per HTTP spec should not be encoded) as | |
71 # utf-8. The exception is x-goog-meta-* headers, which are allowed to contain | |
72 # non-ASCII content (and hence, should be decoded), per | |
73 # https://developers.google.com/storage/docs/gsutil/addlhelp/WorkingWithObject
Metadata | |
74 processing_header = False | |
75 for i in range(len(args)): | |
76 arg = args[i] | |
77 # Commands like mv can run this function twice; don't decode twice. | |
78 try: | |
79 decoded = arg if isinstance(arg, unicode) else arg.decode(UTF8) | |
80 except UnicodeDecodeError: | |
81 raise CommandException('\n'.join(textwrap.wrap( | |
82 'Invalid encoding for argument (%s). Arguments must be decodable as ' | |
83 'Unicode. NOTE: the argument printed above replaces the problematic ' | |
84 'characters with a hex-encoded printable representation. For more ' | |
85 'details (including how to convert to a gsutil-compatible encoding) ' | |
86 'see `gsutil help encoding`.' % repr(arg)))) | |
87 if processing_header: | |
88 if arg.lower().startswith('x-goog-meta'): | |
89 args[i] = decoded | |
90 else: | |
91 try: | |
92 # Try to encode as ASCII to check for invalid header values (which | |
93 # can't be sent over HTTP). | |
94 decoded.encode('ascii') | |
95 except UnicodeEncodeError: | |
96 # Raise the CommandException using the decoded value because | |
97 # _OutputAndExit function re-encodes at the end. | |
98 raise CommandException( | |
99 'Invalid non-ASCII header value (%s).\nOnly ASCII characters are ' | |
100 'allowed in headers other than x-goog-meta- headers' % decoded) | |
101 else: | |
102 args[i] = decoded | |
103 processing_header = (arg in ('-h', '-p')) | |
104 return args | |
105 | |
106 | |
107 class CommandRunner(object): | |
108 """Runs gsutil commands and does some top-level argument handling.""" | |
109 | |
110 def __init__(self, bucket_storage_uri_class=BucketStorageUri, | |
111 gsutil_api_class_map_factory=GsutilApiClassMapFactory, | |
112 command_map=None): | |
113 """Instantiates a CommandRunner. | |
114 | |
115 Args: | |
116 bucket_storage_uri_class: Class to instantiate for cloud StorageUris. | |
117 Settable for testing/mocking. | |
118 gsutil_api_class_map_factory: Creates map of cloud storage interfaces. | |
119 Settable for testing/mocking. | |
120 command_map: Map of command names to their implementations for | |
121 testing/mocking. If not set, the map is built dynamically. | |
122 """ | |
123 self.bucket_storage_uri_class = bucket_storage_uri_class | |
124 self.gsutil_api_class_map_factory = gsutil_api_class_map_factory | |
125 if command_map: | |
126 self.command_map = command_map | |
127 else: | |
128 self.command_map = self._LoadCommandMap() | |
129 | |
130 def _LoadCommandMap(self): | |
131 """Returns dict mapping each command_name to implementing class.""" | |
132 # Import all gslib.commands submodules. | |
133 for _, module_name, _ in pkgutil.iter_modules(gslib.commands.__path__): | |
134 __import__('gslib.commands.%s' % module_name) | |
135 | |
136 command_map = {} | |
137 # Only include Command subclasses in the dict. | |
138 for command in Command.__subclasses__(): | |
139 command_map[command.command_spec.command_name] = command | |
140 for command_name_aliases in command.command_spec.command_name_aliases: | |
141 command_map[command_name_aliases] = command | |
142 return command_map | |
143 | |
144 def _ConfigureCommandArgumentParserArguments( | |
145 self, parser, arguments, gsutil_api): | |
146 """Configures an argument parser with the given arguments. | |
147 | |
148 Args: | |
149 parser: argparse parser object. | |
150 arguments: array of CommandArgument objects. | |
151 gsutil_api: gsutil Cloud API instance to use. | |
152 Raises: | |
153 RuntimeError: if argument is configured with unsupported completer | |
154 """ | |
155 for command_argument in arguments: | |
156 action = parser.add_argument( | |
157 *command_argument.args, **command_argument.kwargs) | |
158 if command_argument.completer: | |
159 action.completer = MakeCompleter(command_argument.completer, gsutil_api) | |
160 | |
161 def ConfigureCommandArgumentParsers(self, subparsers): | |
162 """Configures argparse arguments and argcomplete completers for commands. | |
163 | |
164 Args: | |
165 subparsers: argparse object that can be used to add parsers for | |
166 subcommands (called just 'commands' in gsutil) | |
167 """ | |
168 | |
169 # This should match the support map for the "ls" command. | |
170 support_map = { | |
171 'gs': [ApiSelector.XML, ApiSelector.JSON], | |
172 's3': [ApiSelector.XML] | |
173 } | |
174 default_map = { | |
175 'gs': ApiSelector.JSON, | |
176 's3': ApiSelector.XML | |
177 } | |
178 gsutil_api_map = GsutilApiMapFactory.GetApiMap( | |
179 self.gsutil_api_class_map_factory, support_map, default_map) | |
180 | |
181 logger = CreateGsutilLogger('tab_complete') | |
182 gsutil_api = CloudApiDelegator( | |
183 self.bucket_storage_uri_class, gsutil_api_map, | |
184 logger, debug=0) | |
185 | |
186 for command in set(self.command_map.values()): | |
187 command_parser = subparsers.add_parser( | |
188 command.command_spec.command_name, add_help=False) | |
189 if isinstance(command.command_spec.argparse_arguments, dict): | |
190 subcommand_parsers = command_parser.add_subparsers() | |
191 subcommand_argument_dict = command.command_spec.argparse_arguments | |
192 for subcommand, arguments in subcommand_argument_dict.iteritems(): | |
193 subcommand_parser = subcommand_parsers.add_parser( | |
194 subcommand, add_help=False) | |
195 self._ConfigureCommandArgumentParserArguments( | |
196 subcommand_parser, arguments, gsutil_api) | |
197 else: | |
198 self._ConfigureCommandArgumentParserArguments( | |
199 command_parser, command.command_spec.argparse_arguments, gsutil_api) | |
200 | |
201 def RunNamedCommand(self, command_name, args=None, headers=None, debug=0, | |
202 parallel_operations=False, test_method=None, | |
203 skip_update_check=False, logging_filters=None, | |
204 do_shutdown=True): | |
205 """Runs the named command. | |
206 | |
207 Used by gsutil main, commands built atop other commands, and tests. | |
208 | |
209 Args: | |
210 command_name: The name of the command being run. | |
211 args: Command-line args (arg0 = actual arg, not command name ala bash). | |
212 headers: Dictionary containing optional HTTP headers to pass to boto. | |
213 debug: Debug level to pass in to boto connection (range 0..3). | |
214 parallel_operations: Should command operations be executed in parallel? | |
215 test_method: Optional general purpose method for testing purposes. | |
216 Application and semantics of this method will vary by | |
217 command and test type. | |
218 skip_update_check: Set to True to disable checking for gsutil updates. | |
219 logging_filters: Optional list of logging.Filters to apply to this | |
220 command's logger. | |
221 do_shutdown: Stop all parallelism framework workers iff this is True. | |
222 | |
223 Raises: | |
224 CommandException: if errors encountered. | |
225 | |
226 Returns: | |
227 Return value(s) from Command that was run. | |
228 """ | |
229 if (not skip_update_check and | |
230 self.MaybeCheckForAndOfferSoftwareUpdate(command_name, debug)): | |
231 command_name = 'update' | |
232 args = ['-n'] | |
233 | |
234 if not args: | |
235 args = [] | |
236 | |
237 # Include api_version header in all commands. | |
238 api_version = boto.config.get_value('GSUtil', 'default_api_version', '1') | |
239 if not headers: | |
240 headers = {} | |
241 headers['x-goog-api-version'] = api_version | |
242 | |
243 if command_name not in self.command_map: | |
244 close_matches = difflib.get_close_matches( | |
245 command_name, self.command_map.keys(), n=1) | |
246 if close_matches: | |
247 # Instead of suggesting a deprecated command alias, suggest the new | |
248 # name for that command. | |
249 translated_command_name = ( | |
250 OLD_ALIAS_MAP.get(close_matches[0], close_matches)[0]) | |
251 print >> sys.stderr, 'Did you mean this?' | |
252 print >> sys.stderr, '\t%s' % translated_command_name | |
253 elif command_name == 'update' and gslib.IS_PACKAGE_INSTALL: | |
254 sys.stderr.write( | |
255 'Update command is not supported for package installs; ' | |
256 'please instead update using your package manager.') | |
257 | |
258 raise CommandException('Invalid command "%s".' % command_name) | |
259 if '--help' in args: | |
260 new_args = [command_name] | |
261 original_command_class = self.command_map[command_name] | |
262 subcommands = original_command_class.help_spec.subcommand_help_text.keys() | |
263 for arg in args: | |
264 if arg in subcommands: | |
265 new_args.append(arg) | |
266 break # Take the first match and throw away the rest. | |
267 args = new_args | |
268 command_name = 'help' | |
269 | |
270 args = HandleArgCoding(args) | |
271 | |
272 command_class = self.command_map[command_name] | |
273 command_inst = command_class( | |
274 self, args, headers, debug, parallel_operations, | |
275 self.bucket_storage_uri_class, self.gsutil_api_class_map_factory, | |
276 test_method, logging_filters, command_alias_used=command_name) | |
277 return_code = command_inst.RunCommand() | |
278 | |
279 if MultiprocessingIsAvailable()[0] and do_shutdown: | |
280 ShutDownGsutil() | |
281 if GetFailureCount() > 0: | |
282 return_code = 1 | |
283 return return_code | |
284 | |
285 def MaybeCheckForAndOfferSoftwareUpdate(self, command_name, debug): | |
286 """Checks the last time we checked for an update and offers one if needed. | |
287 | |
288 Offer is made if the time since the last update check is longer | |
289 than the configured threshold offers the user to update gsutil. | |
290 | |
291 Args: | |
292 command_name: The name of the command being run. | |
293 debug: Debug level to pass in to boto connection (range 0..3). | |
294 | |
295 Returns: | |
296 True if the user decides to update. | |
297 """ | |
298 # Don't try to interact with user if: | |
299 # - gsutil is not connected to a tty (e.g., if being run from cron); | |
300 # - user is running gsutil -q | |
301 # - user is running the config command (which could otherwise attempt to | |
302 # check for an update for a user running behind a proxy, who has not yet | |
303 # configured gsutil to go through the proxy; for such users we need the | |
304 # first connection attempt to be made by the gsutil config command). | |
305 # - user is running the version command (which gets run when using | |
306 # gsutil -D, which would prevent users with proxy config problems from | |
307 # sending us gsutil -D output). | |
308 # - user is running the update command (which could otherwise cause an | |
309 # additional note that an update is available when user is already trying | |
310 # to perform an update); | |
311 # - user specified gs_host (which could be a non-production different | |
312 # service instance, in which case credentials won't work for checking | |
313 # gsutil tarball). | |
314 # - user is using a Cloud SDK install (which should only be updated via | |
315 # gcloud components update) | |
316 logger = logging.getLogger() | |
317 gs_host = boto.config.get('Credentials', 'gs_host', None) | |
318 if (not IsRunningInteractively() | |
319 or command_name in ('config', 'update', 'ver', 'version') | |
320 or not logger.isEnabledFor(logging.INFO) | |
321 or gs_host | |
322 or os.environ.get('CLOUDSDK_WRAPPER') == '1'): | |
323 return False | |
324 | |
325 software_update_check_period = boto.config.getint( | |
326 'GSUtil', 'software_update_check_period', 30) | |
327 # Setting software_update_check_period to 0 means periodic software | |
328 # update checking is disabled. | |
329 if software_update_check_period == 0: | |
330 return False | |
331 | |
332 cur_ts = int(time.time()) | |
333 if not os.path.isfile(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE): | |
334 # Set last_checked_ts from date of VERSION file, so if the user installed | |
335 # an old copy of gsutil it will get noticed (and an update offered) the | |
336 # first time they try to run it. | |
337 last_checked_ts = GetGsutilVersionModifiedTime() | |
338 with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'w') as f: | |
339 f.write(str(last_checked_ts)) | |
340 else: | |
341 try: | |
342 with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'r') as f: | |
343 last_checked_ts = int(f.readline()) | |
344 except (TypeError, ValueError): | |
345 return False | |
346 | |
347 if (cur_ts - last_checked_ts | |
348 > software_update_check_period * SECONDS_PER_DAY): | |
349 # Create a credential-less gsutil API to check for the public | |
350 # update tarball. | |
351 gsutil_api = GcsJsonApi(self.bucket_storage_uri_class, logger, | |
352 credentials=NoOpCredentials(), debug=debug) | |
353 | |
354 cur_ver = LookUpGsutilVersion(gsutil_api, GSUTIL_PUB_TARBALL) | |
355 with open(LAST_CHECKED_FOR_GSUTIL_UPDATE_TIMESTAMP_FILE, 'w') as f: | |
356 f.write(str(cur_ts)) | |
357 (g, m) = CompareVersions(cur_ver, gslib.VERSION) | |
358 if m: | |
359 print '\n'.join(textwrap.wrap( | |
360 'A newer version of gsutil (%s) is available than the version you ' | |
361 'are running (%s). NOTE: This is a major new version, so it is ' | |
362 'strongly recommended that you review the release note details at ' | |
363 '%s before updating to this version, especially if you use gsutil ' | |
364 'in scripts.' % (cur_ver, gslib.VERSION, RELEASE_NOTES_URL))) | |
365 if gslib.IS_PACKAGE_INSTALL: | |
366 return False | |
367 print | |
368 answer = raw_input('Would you like to update [y/N]? ') | |
369 return answer and answer.lower()[0] == 'y' | |
370 elif g: | |
371 print '\n'.join(textwrap.wrap( | |
372 'A newer version of gsutil (%s) is available than the version you ' | |
373 'are running (%s). A detailed log of gsutil release changes is ' | |
374 'available at %s if you would like to read them before updating.' | |
375 % (cur_ver, gslib.VERSION, RELEASE_NOTES_URL))) | |
376 if gslib.IS_PACKAGE_INSTALL: | |
377 return False | |
378 print | |
379 answer = raw_input('Would you like to update [Y/n]? ') | |
380 return not answer or answer.lower()[0] != 'n' | |
381 return False | |
OLD | NEW |