arvados.commands.put
1# Copyright (C) The Arvados Authors. All rights reserved. 2# 3# SPDX-License-Identifier: Apache-2.0 4 5import argparse 6import arvados 7import arvados.collection 8import base64 9import ciso8601 10import copy 11import datetime 12import errno 13import fcntl 14import fnmatch 15import hashlib 16import json 17import logging 18import os 19import pwd 20import re 21import signal 22import socket 23import sys 24import tempfile 25import threading 26import time 27import traceback 28 29from pathlib import Path 30 31import arvados.util 32import arvados.commands._util as arv_cmd 33 34from apiclient import errors as apiclient_errors 35from arvados._internal import basedirs 36from arvados._version import __version__ 37 38api_client = None 39 40 41def get_run_opts(): 42 """Returns an argparse.ArgumentParser instances that includes 43 a subset of parameters used both by this script ('arv put') and also reused 44 by 'arv keep docker'. 45 """ 46 run_opts = argparse.ArgumentParser(add_help=False) 47 48 run_opts.add_argument('--project-uuid', metavar='UUID', help=""" 49 Store the collection in the specified project, instead of your Home 50 project. 51 """) 52 53 run_opts.add_argument('--name', help=""" 54 Save the collection with the specified name. 55 """) 56 57 _group = run_opts.add_mutually_exclusive_group() 58 _group.add_argument('--progress', action='store_true', 59 help=""" 60 Display human-readable progress on stderr (bytes and, if possible, 61 percentage of total data size). This is the default behavior when 62 stderr is a tty. 63 """) 64 65 _group.add_argument('--no-progress', action='store_true', 66 help=""" 67 Do not display human-readable progress on stderr, even if stderr is a 68 tty. 69 """) 70 71 _group.add_argument('--batch-progress', action='store_true', 72 help=""" 73 Display machine-readable progress on stderr (bytes and, if known, 74 total data size). 75 """) 76 77 run_opts.add_argument('--silent', action='store_true', 78 help=""" 79 Do not print any debug messages to console. (Any error messages will 80 still be displayed.) 81 """) 82 83 run_opts.add_argument('--batch', action='store_true', default=False, 84 help=""" 85 Retries with '--no-resume --no-cache' if cached state contains invalid/expired 86 block signatures. 87 """) 88 89 _group = run_opts.add_mutually_exclusive_group() 90 _group.add_argument('--resume', action='store_true', default=True, 91 help=""" 92 Continue interrupted uploads from cached state (default). 93 """) 94 _group.add_argument('--no-resume', action='store_false', dest='resume', 95 help=""" 96 Do not continue interrupted uploads from cached state. 97 """) 98 99 _group = run_opts.add_mutually_exclusive_group() 100 _group.add_argument('--cache', action='store_true', dest='use_cache', default=True, 101 help=""" 102 Save upload state in a cache file for resuming (default). 103 """) 104 _group.add_argument('--no-cache', action='store_false', dest='use_cache', 105 help=""" 106 Do not save upload state in a cache file for resuming. 107 """) 108 109 return run_opts 110 111 112def get_argument_parser(): 113 """Returns an argparse.ArgumentParser instance, the main argument parser 114 used by 'arv put', given the shared argument parser instance. 115 """ 116 upload_opts = argparse.ArgumentParser(add_help=False) 117 118 upload_opts.add_argument('--version', action='version', 119 version=f"%(prog)s {__version__}", 120 help='Print version and exit.') 121 upload_opts.add_argument('paths', metavar='path', type=str, nargs='*', 122 help=""" 123 Local file or directory. If path is a directory reference with a trailing 124 slash, then just upload the directory's contents; otherwise upload the 125 directory itself. Default: read from standard input. 126 """) 127 128 _group = upload_opts.add_mutually_exclusive_group() 129 130 _group.add_argument('--max-manifest-depth', type=int, metavar='N', 131 default=-1, help=argparse.SUPPRESS) 132 133 _group.add_argument('--normalize', action='store_true', 134 help=""" 135 Normalize the manifest by re-ordering files and streams after writing 136 data. 137 """) 138 139 _group.add_argument('--dry-run', action='store_true', default=False, 140 help=""" 141 Don't actually upload files, but only check if any file should be 142 uploaded. Exit with code=2 when files are pending for upload. 143 """) 144 145 _group = upload_opts.add_mutually_exclusive_group() 146 147 _group.add_argument('--as-stream', action='store_true', dest='stream', 148 help=""" 149 Synonym for --stream. 150 """) 151 152 _group.add_argument('--stream', action='store_true', 153 help=""" 154 Store the file content and display the resulting manifest on 155 stdout. Do not save a Collection object in Arvados. 156 """) 157 158 _group.add_argument('--as-manifest', action='store_true', dest='manifest', 159 help=""" 160 Synonym for --manifest. 161 """) 162 163 _group.add_argument('--in-manifest', action='store_true', dest='manifest', 164 help=""" 165 Synonym for --manifest. 166 """) 167 168 _group.add_argument('--manifest', action='store_true', 169 help=""" 170 Store the file data and resulting manifest in Keep, save a Collection 171 object in Arvados, and display the manifest locator (Collection uuid) 172 on stdout. This is the default behavior. 173 """) 174 175 _group.add_argument('--as-raw', action='store_true', dest='raw', 176 help=""" 177 Synonym for --raw. 178 """) 179 180 _group.add_argument('--raw', action='store_true', 181 help=""" 182 Store the file content and display the data block locators on stdout, 183 separated by commas, with a trailing newline. Do not store a 184 manifest. 185 """) 186 187 upload_opts.add_argument('--update-collection', type=str, default=None, 188 dest='update_collection', metavar="UUID", help=""" 189 Update an existing collection identified by the given Arvados collection 190 UUID. All new local files will be uploaded. 191 """) 192 193 upload_opts.add_argument('--use-filename', type=str, default=None, 194 dest='filename', help=""" 195 Synonym for --filename. 196 """) 197 198 upload_opts.add_argument('--filename', type=str, default=None, 199 help=""" 200 Use the given filename in the manifest, instead of the name of the 201 local file. This is useful when "-" or "/dev/stdin" is given as an 202 input file. It can be used only if there is exactly one path given and 203 it is not a directory. Implies --manifest. 204 """) 205 206 upload_opts.add_argument('--portable-data-hash', action='store_true', 207 help=""" 208 Print the portable data hash instead of the Arvados UUID for the collection 209 created by the upload. 210 """) 211 212 upload_opts.add_argument('--replication', type=int, metavar='N', 213 default=None, 214 help=""" 215 Set the replication level for the new collection: how many different 216 physical storage devices (e.g., disks) should have a copy of each data 217 block. Default is to use the server-provided default (if any) or 2. 218 """) 219 220 upload_opts.add_argument( 221 '--storage-classes', 222 type=arv_cmd.UniqueSplit(), 223 help=""" 224 Specify comma separated list of storage classes to be used when saving data to Keep. 225 """) 226 227 upload_opts.add_argument('--threads', type=int, metavar='N', default=None, 228 help=""" 229 Set the number of upload threads to be used. Take into account that 230 using lots of threads will increase the RAM requirements. Default is 231 to use 2 threads. 232 On high latency installations, using a greater number will improve 233 overall throughput. 234 """) 235 236 upload_opts.add_argument('--exclude', metavar='PATTERN', default=[], 237 action='append', help=""" 238 Exclude files and directories whose names match the given glob pattern. When 239 using a path-like pattern like 'subdir/*.txt', all text files inside 'subdir' 240 directory, relative to the provided input dirs will be excluded. 241 When using a filename pattern like '*.txt', any text file will be excluded 242 no matter where it is placed. 243 For the special case of needing to exclude only files or dirs directly below 244 the given input directory, you can use a pattern like './exclude_this.gif'. 245 You can specify multiple patterns by using this argument more than once. 246 """) 247 248 _group = upload_opts.add_mutually_exclusive_group() 249 _group.add_argument('--follow-links', action='store_true', default=True, 250 dest='follow_links', help=""" 251 Follow file and directory symlinks (default). 252 """) 253 _group.add_argument('--no-follow-links', action='store_false', dest='follow_links', 254 help=""" 255 Ignore file and directory symlinks. Even paths given explicitly on the 256 command line will be skipped if they are symlinks. 257 """) 258 259 _group = upload_opts.add_mutually_exclusive_group() 260 _group.add_argument('--trash-at', metavar='YYYY-MM-DDTHH:MM', default=None, 261 help=""" 262 Set the trash date of the resulting collection to an absolute date in the future. 263 The accepted format is defined by the ISO 8601 standard. Examples: 20090103, 2009-01-03, 20090103T181505, 2009-01-03T18:15:05.\n 264 Timezone information can be added. If not, the provided date/time is assumed as being in the local system's timezone. 265 """) 266 _group.add_argument('--trash-after', type=int, metavar='DAYS', default=None, 267 help=""" 268 Set the trash date of the resulting collection to an amount of days from the 269 date/time that the upload process finishes. 270 """) 271 272 return argparse.ArgumentParser( 273 description='Copy data from the local filesystem to Keep.', 274 parents=[upload_opts, get_run_opts(), arv_cmd.retry_opt]) 275 276 277def parse_arguments(arguments): 278 arg_parser = get_argument_parser() 279 args = arg_parser.parse_args(arguments) 280 281 if len(args.paths) == 0: 282 args.paths = ['-'] 283 284 args.paths = ["-" if x == "/dev/stdin" else x for x in args.paths] 285 286 if args.filename and (len(args.paths) != 1 or os.path.isdir(args.paths[0])): 287 arg_parser.error(""" 288 --filename argument cannot be used when storing a directory or 289 multiple files. 290 """) 291 292 # Turn on --progress by default if stderr is a tty. 293 if (not (args.batch_progress or args.no_progress or args.silent) 294 and sys.stderr.isatty()): 295 args.progress = True 296 297 # Turn off --resume (default) if --no-cache is used. 298 if not args.use_cache: 299 args.resume = False 300 301 if args.paths == ['-']: 302 if args.update_collection: 303 arg_parser.error(""" 304 --update-collection cannot be used when reading from stdin. 305 """) 306 args.resume = False 307 args.use_cache = False 308 if not args.filename: 309 args.filename = 'stdin' 310 311 # Remove possible duplicated patterns 312 if len(args.exclude) > 0: 313 args.exclude = list(set(args.exclude)) 314 315 return args 316 317 318class PathDoesNotExistError(Exception): 319 pass 320 321 322class CollectionUpdateError(Exception): 323 pass 324 325 326class ResumeCacheConflict(Exception): 327 pass 328 329 330class ResumeCacheInvalidError(Exception): 331 pass 332 333class ArvPutArgumentConflict(Exception): 334 pass 335 336 337class ArvPutUploadIsPending(Exception): 338 pass 339 340 341class ArvPutUploadNotPending(Exception): 342 pass 343 344 345class FileUploadList(list): 346 def __init__(self, dry_run=False): 347 list.__init__(self) 348 self.dry_run = dry_run 349 350 def append(self, other): 351 if self.dry_run: 352 raise ArvPutUploadIsPending() 353 super(FileUploadList, self).append(other) 354 355 356# Appends the X-Request-Id to the log message when log level is ERROR or DEBUG 357class ArvPutLogFormatter(logging.Formatter): 358 std_fmtr = logging.Formatter(arvados.log_format, arvados.log_date_format) 359 err_fmtr = None 360 request_id_informed = False 361 362 def __init__(self, request_id): 363 self.err_fmtr = logging.Formatter( 364 arvados.log_format+' (X-Request-Id: {})'.format(request_id), 365 arvados.log_date_format) 366 367 def format(self, record): 368 if (not self.request_id_informed) and (record.levelno in (logging.DEBUG, logging.ERROR)): 369 self.request_id_informed = True 370 return self.err_fmtr.format(record) 371 return self.std_fmtr.format(record) 372 373 374class ResumeCache(object): 375 CACHE_DIR = 'arv-put' 376 377 def __init__(self, file_spec): 378 self.cache_file = open(file_spec, 'a+') 379 self._lock_file(self.cache_file) 380 self.filename = self.cache_file.name 381 382 @classmethod 383 def make_path(cls, args): 384 md5 = hashlib.md5() 385 md5.update(arvados.config.get('ARVADOS_API_HOST', '!nohost').encode()) 386 realpaths = sorted(os.path.realpath(path) for path in args.paths) 387 md5.update(b'\0'.join([p.encode() for p in realpaths])) 388 if any(os.path.isdir(path) for path in realpaths): 389 md5.update(b'-1') 390 elif args.filename: 391 md5.update(args.filename.encode()) 392 cache_path = Path(cls.CACHE_DIR) 393 if len(cache_path.parts) == 1: 394 cache_path = basedirs.BaseDirectories('CACHE').storage_path(cache_path) 395 else: 396 # Note this is a noop if cache_path is absolute, which is what we want. 397 cache_path = Path.home() / cache_path 398 cache_path.mkdir(parents=True, exist_ok=True, mode=0o700) 399 return str(cache_path / md5.hexdigest()) 400 401 def _lock_file(self, fileobj): 402 try: 403 fcntl.flock(fileobj, fcntl.LOCK_EX | fcntl.LOCK_NB) 404 except IOError: 405 raise ResumeCacheConflict(u"{} locked".format(fileobj.name)) 406 407 def load(self): 408 self.cache_file.seek(0) 409 return json.load(self.cache_file) 410 411 def check_cache(self, api_client=None, num_retries=0): 412 try: 413 state = self.load() 414 locator = None 415 try: 416 if "_finished_streams" in state and len(state["_finished_streams"]) > 0: 417 locator = state["_finished_streams"][0][1][0] 418 elif "_current_stream_locators" in state and len(state["_current_stream_locators"]) > 0: 419 locator = state["_current_stream_locators"][0] 420 if locator is not None: 421 kc = arvados.keep.KeepClient(api_client=api_client) 422 kc.head(locator, num_retries=num_retries) 423 except Exception as e: 424 self.restart() 425 except (ValueError): 426 pass 427 428 def save(self, data): 429 try: 430 new_cache_fd, new_cache_name = tempfile.mkstemp( 431 dir=os.path.dirname(self.filename)) 432 self._lock_file(new_cache_fd) 433 new_cache = os.fdopen(new_cache_fd, 'r+') 434 json.dump(data, new_cache) 435 os.rename(new_cache_name, self.filename) 436 except (IOError, OSError, ResumeCacheConflict): 437 try: 438 os.unlink(new_cache_name) 439 except NameError: # mkstemp failed. 440 pass 441 else: 442 self.cache_file.close() 443 self.cache_file = new_cache 444 445 def close(self): 446 self.cache_file.close() 447 448 def destroy(self): 449 try: 450 os.unlink(self.filename) 451 except OSError as error: 452 if error.errno != errno.ENOENT: # That's what we wanted anyway. 453 raise 454 self.close() 455 456 def restart(self): 457 self.destroy() 458 self.__init__(self.filename) 459 460 461class ArvPutUploadJob(object): 462 CACHE_DIR = 'arv-put' 463 EMPTY_STATE = { 464 'manifest' : None, # Last saved manifest checkpoint 465 'files' : {} # Previous run file list: {path : {size, mtime}} 466 } 467 468 def __init__(self, paths, resume=True, use_cache=True, reporter=None, 469 name=None, owner_uuid=None, api_client=None, batch_mode=False, 470 ensure_unique_name=False, num_retries=None, 471 put_threads=None, replication_desired=None, filename=None, 472 update_time=60.0, update_collection=None, storage_classes=None, 473 logger=logging.getLogger('arvados.arv_put'), dry_run=False, 474 follow_links=True, exclude_paths=[], exclude_names=None, 475 trash_at=None): 476 self.paths = paths 477 self.resume = resume 478 self.use_cache = use_cache 479 self.batch_mode = batch_mode 480 self.update = False 481 self.reporter = reporter 482 # This will set to 0 before start counting, if no special files are going 483 # to be read. 484 self.bytes_expected = None 485 self.bytes_written = 0 486 self.bytes_skipped = 0 487 self.name = name 488 self.owner_uuid = owner_uuid 489 self.ensure_unique_name = ensure_unique_name 490 self.num_retries = num_retries 491 self.replication_desired = replication_desired 492 self.put_threads = put_threads 493 self.filename = filename 494 self.storage_classes = storage_classes 495 self._api_client = api_client 496 self._state_lock = threading.Lock() 497 self._state = None # Previous run state (file list & manifest) 498 self._current_files = [] # Current run file list 499 self._cache_file = None 500 self._collection_lock = threading.Lock() 501 self._remote_collection = None # Collection being updated (if asked) 502 self._local_collection = None # Collection from previous run manifest 503 self._file_paths = set() # Files to be updated in remote collection 504 self._stop_checkpointer = threading.Event() 505 self._checkpointer = threading.Thread(target=self._update_task) 506 self._checkpointer.daemon = True 507 self._update_task_time = update_time # How many seconds wait between update runs 508 self._files_to_upload = FileUploadList(dry_run=dry_run) 509 self._upload_started = False 510 self.logger = logger 511 self.dry_run = dry_run 512 self._checkpoint_before_quit = True 513 self.follow_links = follow_links 514 self.exclude_paths = exclude_paths 515 self.exclude_names = exclude_names 516 self._trash_at = trash_at 517 518 if self._trash_at is not None: 519 if type(self._trash_at) not in [datetime.datetime, datetime.timedelta]: 520 raise TypeError('trash_at should be None, timezone-naive datetime or timedelta') 521 if type(self._trash_at) == datetime.datetime and self._trash_at.tzinfo is not None: 522 raise TypeError('provided trash_at datetime should be timezone-naive') 523 524 if not self.use_cache and self.resume: 525 raise ArvPutArgumentConflict('resume cannot be True when use_cache is False') 526 527 # Check for obvious dry-run responses 528 if self.dry_run and (not self.use_cache or not self.resume): 529 raise ArvPutUploadIsPending() 530 531 # Load cached data if any and if needed 532 self._setup_state(update_collection) 533 534 # Build the upload file list, excluding requested files and counting the 535 # bytes expected to be uploaded. 536 self._build_upload_list() 537 538 def _build_upload_list(self): 539 """ 540 Scan the requested paths to count file sizes, excluding requested files 541 and dirs and building the upload file list. 542 """ 543 # If there aren't special files to be read, reset total bytes count to zero 544 # to start counting. 545 if not any([p for p in self.paths 546 if not (os.path.isfile(p) or os.path.isdir(p))]): 547 self.bytes_expected = 0 548 549 for path in self.paths: 550 # Test for stdin first, in case some file named '-' exist 551 if path == '-': 552 if self.dry_run: 553 raise ArvPutUploadIsPending() 554 self._write_stdin(self.filename or 'stdin') 555 elif not os.path.exists(path): 556 raise PathDoesNotExistError(u"file or directory '{}' does not exist.".format(path)) 557 elif (not self.follow_links) and os.path.islink(path): 558 self.logger.warning("Skipping symlink '{}'".format(path)) 559 continue 560 elif os.path.isdir(path): 561 # Use absolute paths on cache index so CWD doesn't interfere 562 # with the caching logic. 563 orig_path = path 564 path = os.path.abspath(path) 565 if orig_path[-1:] == os.sep: 566 # When passing a directory reference with a trailing slash, 567 # its contents should be uploaded directly to the 568 # collection's root. 569 prefixdir = path 570 else: 571 # When passing a directory reference with no trailing slash, 572 # upload the directory to the collection's root. 573 prefixdir = os.path.dirname(path) 574 prefixdir += os.sep 575 for root, dirs, files in os.walk(path, 576 followlinks=self.follow_links): 577 root_relpath = os.path.relpath(root, path) 578 if root_relpath == '.': 579 root_relpath = '' 580 # Exclude files/dirs by full path matching pattern 581 if self.exclude_paths: 582 dirs[:] = [d for d in dirs 583 if not any(pathname_match( 584 os.path.join(root_relpath, d), pat) 585 for pat in self.exclude_paths)] 586 files = [f for f in files 587 if not any(pathname_match( 588 os.path.join(root_relpath, f), pat) 589 for pat in self.exclude_paths)] 590 # Exclude files/dirs by name matching pattern 591 if self.exclude_names is not None: 592 dirs[:] = [d for d in dirs 593 if not self.exclude_names.match(d)] 594 files = [f for f in files 595 if not self.exclude_names.match(f)] 596 # Make os.walk()'s dir traversing order deterministic 597 dirs.sort() 598 files.sort() 599 for f in files: 600 filepath = os.path.join(root, f) 601 if not os.path.isfile(filepath): 602 self.logger.warning("Skipping non-regular file '{}'".format(filepath)) 603 continue 604 # Add its size to the total bytes count (if applicable) 605 if self.follow_links or (not os.path.islink(filepath)): 606 if self.bytes_expected is not None: 607 self.bytes_expected += os.path.getsize(filepath) 608 self._check_file(filepath, 609 os.path.join(root[len(prefixdir):], f)) 610 else: 611 filepath = os.path.abspath(path) 612 # Add its size to the total bytes count (if applicable) 613 if self.follow_links or (not os.path.islink(filepath)): 614 if self.bytes_expected is not None: 615 self.bytes_expected += os.path.getsize(filepath) 616 self._check_file(filepath, 617 self.filename or os.path.basename(path)) 618 # If dry-mode is on, and got up to this point, then we should notify that 619 # there aren't any file to upload. 620 if self.dry_run: 621 raise ArvPutUploadNotPending() 622 # Remove local_collection's files that don't exist locally anymore, so the 623 # bytes_written count is correct. 624 for f in self.collection_file_paths(self._local_collection, 625 path_prefix=""): 626 if f != 'stdin' and f != self.filename and not f in self._file_paths: 627 self._local_collection.remove(f) 628 629 def start(self, save_collection): 630 """ 631 Start supporting thread & file uploading 632 """ 633 self._checkpointer.start() 634 try: 635 # Update bytes_written from current local collection and 636 # report initial progress. 637 self._update() 638 # Actual file upload 639 self._upload_started = True # Used by the update thread to start checkpointing 640 self._upload_files() 641 except (SystemExit, Exception) as e: 642 self._checkpoint_before_quit = False 643 # Log stack trace only when Ctrl-C isn't pressed (SIGINT) 644 # Note: We're expecting SystemExit instead of 645 # KeyboardInterrupt because we have a custom signal 646 # handler in place that raises SystemExit with the catched 647 # signal's code. 648 if isinstance(e, PathDoesNotExistError): 649 # We aren't interested in the traceback for this case 650 pass 651 elif not isinstance(e, SystemExit) or e.code != -2: 652 self.logger.warning("Abnormal termination:\n{}".format( 653 traceback.format_exc())) 654 raise 655 finally: 656 if not self.dry_run: 657 # Stop the thread before doing anything else 658 self._stop_checkpointer.set() 659 self._checkpointer.join() 660 if self._checkpoint_before_quit: 661 # Commit all pending blocks & one last _update() 662 self._local_collection.manifest_text() 663 self._update(final=True) 664 if save_collection: 665 self.save_collection() 666 if self.use_cache: 667 self._cache_file.close() 668 669 def _collection_trash_at(self): 670 """ 671 Returns the trash date that the collection should use at save time. 672 Takes into account absolute/relative trash_at values requested 673 by the user. 674 """ 675 if type(self._trash_at) == datetime.timedelta: 676 # Get an absolute datetime for trash_at 677 return datetime.datetime.utcnow() + self._trash_at 678 return self._trash_at 679 680 def save_collection(self): 681 if self.update: 682 # Check if files should be updated on the remote collection. 683 for fp in self._file_paths: 684 remote_file = self._remote_collection.find(fp) 685 if not remote_file: 686 # File don't exist on remote collection, copy it. 687 self._remote_collection.copy(fp, fp, self._local_collection) 688 elif remote_file != self._local_collection.find(fp): 689 # A different file exist on remote collection, overwrite it. 690 self._remote_collection.copy(fp, fp, self._local_collection, overwrite=True) 691 else: 692 # The file already exist on remote collection, skip it. 693 pass 694 self._remote_collection.save(num_retries=self.num_retries, 695 trash_at=self._collection_trash_at()) 696 else: 697 if len(self._local_collection) == 0: 698 self.logger.warning("No files were uploaded, skipping collection creation.") 699 return 700 self._local_collection.save_new( 701 name=self.name, owner_uuid=self.owner_uuid, 702 ensure_unique_name=self.ensure_unique_name, 703 num_retries=self.num_retries, 704 trash_at=self._collection_trash_at()) 705 706 def destroy_cache(self): 707 if self.use_cache: 708 try: 709 os.unlink(self._cache_filename) 710 except OSError as error: 711 # That's what we wanted anyway. 712 if error.errno != errno.ENOENT: 713 raise 714 self._cache_file.close() 715 716 def _collection_size(self, collection): 717 """ 718 Recursively get the total size of the collection 719 """ 720 size = 0 721 for item in collection.values(): 722 if isinstance(item, arvados.collection.Collection) or isinstance(item, arvados.collection.Subcollection): 723 size += self._collection_size(item) 724 else: 725 size += item.size() 726 return size 727 728 def _update_task(self): 729 """ 730 Periodically called support task. File uploading is 731 asynchronous so we poll status from the collection. 732 """ 733 while not self._stop_checkpointer.wait(1 if not self._upload_started else self._update_task_time): 734 self._update() 735 736 def _update(self, final=False): 737 """ 738 Update cached manifest text and report progress. 739 """ 740 if self._upload_started: 741 with self._collection_lock: 742 self.bytes_written = self._collection_size(self._local_collection) 743 if self.use_cache: 744 if final: 745 manifest = self._local_collection.manifest_text() 746 else: 747 # Get the manifest text without comitting pending blocks 748 manifest = self._local_collection.manifest_text(strip=False, 749 normalize=False, 750 only_committed=True) 751 # Update cache 752 with self._state_lock: 753 self._state['manifest'] = manifest 754 if self.use_cache: 755 try: 756 self._save_state() 757 except Exception as e: 758 self.logger.error("Unexpected error trying to save cache file: {}".format(e)) 759 # Keep remote collection's trash_at attribute synced when using relative expire dates 760 if self._remote_collection is not None and type(self._trash_at) == datetime.timedelta: 761 try: 762 self._api_client.collections().update( 763 uuid=self._remote_collection.manifest_locator(), 764 body={'trash_at': self._collection_trash_at().strftime("%Y-%m-%dT%H:%M:%S.%fZ")} 765 ).execute(num_retries=self.num_retries) 766 except Exception as e: 767 self.logger.error("Unexpected error trying to update remote collection's expire date: {}".format(e)) 768 else: 769 self.bytes_written = self.bytes_skipped 770 # Call the reporter, if any 771 self.report_progress() 772 773 def report_progress(self): 774 if self.reporter is not None: 775 self.reporter(self.bytes_written, self.bytes_expected) 776 777 def _write_stdin(self, filename): 778 output = self._local_collection.open(filename, 'wb') 779 self._write(sys.stdin.buffer, output) 780 output.close() 781 782 def _check_file(self, source, filename): 783 """ 784 Check if this file needs to be uploaded 785 """ 786 # Ignore symlinks when requested 787 if (not self.follow_links) and os.path.islink(source): 788 return 789 resume_offset = 0 790 should_upload = False 791 new_file_in_cache = False 792 # Record file path for updating the remote collection before exiting 793 self._file_paths.add(filename) 794 795 with self._state_lock: 796 # If no previous cached data on this file, store it for an eventual 797 # repeated run. 798 if source not in self._state['files']: 799 self._state['files'][source] = { 800 'mtime': os.path.getmtime(source), 801 'size' : os.path.getsize(source) 802 } 803 new_file_in_cache = True 804 cached_file_data = self._state['files'][source] 805 806 # Check if file was already uploaded (at least partially) 807 file_in_local_collection = self._local_collection.find(filename) 808 809 # If not resuming, upload the full file. 810 if not self.resume: 811 should_upload = True 812 # New file detected from last run, upload it. 813 elif new_file_in_cache: 814 should_upload = True 815 # Local file didn't change from last run. 816 elif cached_file_data['mtime'] == os.path.getmtime(source) and cached_file_data['size'] == os.path.getsize(source): 817 if not file_in_local_collection: 818 # File not uploaded yet, upload it completely 819 should_upload = True 820 elif file_in_local_collection.permission_expired(): 821 # Permission token expired, re-upload file. This will change whenever 822 # we have a API for refreshing tokens. 823 self.logger.warning(u"Uploaded file '{}' access token expired, will re-upload it from scratch".format(filename)) 824 should_upload = True 825 self._local_collection.remove(filename) 826 elif cached_file_data['size'] == file_in_local_collection.size(): 827 # File already there, skip it. 828 self.bytes_skipped += cached_file_data['size'] 829 elif cached_file_data['size'] > file_in_local_collection.size(): 830 # File partially uploaded, resume! 831 resume_offset = file_in_local_collection.size() 832 self.bytes_skipped += resume_offset 833 should_upload = True 834 else: 835 # Inconsistent cache, re-upload the file 836 should_upload = True 837 self._local_collection.remove(filename) 838 self.logger.warning(u"Uploaded version of file '{}' is bigger than local version, will re-upload it from scratch.".format(source)) 839 # Local file differs from cached data, re-upload it. 840 else: 841 if file_in_local_collection: 842 self._local_collection.remove(filename) 843 should_upload = True 844 845 if should_upload: 846 try: 847 self._files_to_upload.append((source, resume_offset, filename)) 848 except ArvPutUploadIsPending: 849 # This could happen when running on dry-mode, close cache file to 850 # avoid locking issues. 851 self._cache_file.close() 852 raise 853 854 def _upload_files(self): 855 for source, resume_offset, filename in self._files_to_upload: 856 with open(source, 'rb') as source_fd: 857 with self._state_lock: 858 self._state['files'][source]['mtime'] = os.path.getmtime(source) 859 self._state['files'][source]['size'] = os.path.getsize(source) 860 if resume_offset > 0: 861 # Start upload where we left off 862 output = self._local_collection.open(filename, 'ab') 863 source_fd.seek(resume_offset) 864 else: 865 # Start from scratch 866 output = self._local_collection.open(filename, 'wb') 867 self._write(source_fd, output) 868 output.close(flush=False) 869 870 def _write(self, source_fd, output): 871 while True: 872 data = source_fd.read(arvados.config.KEEP_BLOCK_SIZE) 873 if not data: 874 break 875 output.write(data) 876 877 def _my_collection(self): 878 return self._remote_collection if self.update else self._local_collection 879 880 def _get_cache_filepath(self): 881 # Set up cache file name from input paths. 882 md5 = hashlib.md5() 883 md5.update(arvados.config.get('ARVADOS_API_HOST', '!nohost').encode()) 884 realpaths = sorted(os.path.realpath(path) for path in self.paths) 885 md5.update(b'\0'.join([p.encode() for p in realpaths])) 886 if self.filename: 887 md5.update(self.filename.encode()) 888 cache_path = Path(self.CACHE_DIR) 889 if len(cache_path.parts) == 1: 890 cache_path = basedirs.BaseDirectories('CACHE').storage_path(cache_path) 891 else: 892 # Note this is a noop if cache_path is absolute, which is what we want. 893 cache_path = Path.home() / cache_path 894 cache_path.mkdir(parents=True, exist_ok=True, mode=0o700) 895 return str(cache_path / md5.hexdigest()) 896 897 def _setup_state(self, update_collection): 898 """ 899 Create a new cache file or load a previously existing one. 900 """ 901 # Load an already existing collection for update 902 if update_collection and re.match(arvados.util.collection_uuid_pattern, 903 update_collection): 904 try: 905 self._remote_collection = arvados.collection.Collection( 906 update_collection, 907 api_client=self._api_client, 908 storage_classes_desired=self.storage_classes, 909 num_retries=self.num_retries) 910 except arvados.errors.ApiError as error: 911 raise CollectionUpdateError("Cannot read collection {} ({})".format(update_collection, error)) 912 else: 913 self.update = True 914 elif update_collection: 915 # Collection locator provided, but unknown format 916 raise CollectionUpdateError("Collection locator unknown: '{}'".format(update_collection)) 917 918 if self.use_cache: 919 cache_filepath = self._get_cache_filepath() 920 if self.resume and os.path.exists(cache_filepath): 921 self.logger.info(u"Resuming upload from cache file {}".format(cache_filepath)) 922 self._cache_file = open(cache_filepath, 'a+') 923 else: 924 # --no-resume means start with a empty cache file. 925 self.logger.info(u"Creating new cache file at {}".format(cache_filepath)) 926 self._cache_file = open(cache_filepath, 'w+') 927 self._cache_filename = self._cache_file.name 928 self._lock_file(self._cache_file) 929 self._cache_file.seek(0) 930 931 with self._state_lock: 932 if self.use_cache: 933 try: 934 self._state = json.load(self._cache_file) 935 if not set(['manifest', 'files']).issubset(set(self._state.keys())): 936 # Cache at least partially incomplete, set up new cache 937 self._state = copy.deepcopy(self.EMPTY_STATE) 938 except ValueError: 939 # Cache file empty, set up new cache 940 self._state = copy.deepcopy(self.EMPTY_STATE) 941 else: 942 self.logger.info("No cache usage requested for this run.") 943 # No cache file, set empty state 944 self._state = copy.deepcopy(self.EMPTY_STATE) 945 if not self._cached_manifest_valid(): 946 if not self.batch_mode: 947 raise ResumeCacheInvalidError() 948 else: 949 self.logger.info("Invalid signatures on cache file '{}' while being run in 'batch mode' -- continuing anyways.".format(self._cache_file.name)) 950 self.use_cache = False # Don't overwrite preexisting cache file. 951 self._state = copy.deepcopy(self.EMPTY_STATE) 952 # Load the previous manifest so we can check if files were modified remotely. 953 self._local_collection = arvados.collection.Collection( 954 self._state['manifest'], 955 replication_desired=self.replication_desired, 956 storage_classes_desired=self.storage_classes, 957 put_threads=self.put_threads, 958 api_client=self._api_client, 959 num_retries=self.num_retries) 960 961 def _cached_manifest_valid(self): 962 """ 963 Validate the oldest non-expired block signature to check if cached manifest 964 is usable: checking if the cached manifest was not created with a different 965 arvados account. 966 """ 967 if self._state.get('manifest', None) is None: 968 # No cached manifest yet, all good. 969 return True 970 now = datetime.datetime.utcnow() 971 oldest_exp = None 972 oldest_loc = None 973 block_found = False 974 for m in arvados.util.keep_locator_pattern.finditer(self._state['manifest']): 975 loc = m.group(0) 976 try: 977 exp = datetime.datetime.utcfromtimestamp(int(loc.split('@')[1], 16)) 978 except IndexError: 979 # Locator without signature 980 continue 981 block_found = True 982 if exp > now and (oldest_exp is None or exp < oldest_exp): 983 oldest_exp = exp 984 oldest_loc = loc 985 if not block_found: 986 # No block signatures found => no invalid block signatures. 987 return True 988 if oldest_loc is None: 989 # Locator signatures found, but all have expired. 990 # Reset the cache and move on. 991 self.logger.info('Cache expired, starting from scratch.') 992 self._state['manifest'] = '' 993 return True 994 kc = arvados.KeepClient(api_client=self._api_client, 995 num_retries=self.num_retries) 996 try: 997 kc.head(oldest_loc) 998 except arvados.errors.KeepRequestError: 999 # Something is wrong, cached manifest is not valid. 1000 return False 1001 return True 1002 1003 def collection_file_paths(self, col, path_prefix='.'): 1004 """Return a list of file paths by recursively go through the entire collection `col`""" 1005 file_paths = [] 1006 for name, item in col.items(): 1007 if isinstance(item, arvados.arvfile.ArvadosFile): 1008 file_paths.append(os.path.join(path_prefix, name)) 1009 elif isinstance(item, arvados.collection.Subcollection): 1010 new_prefix = os.path.join(path_prefix, name) 1011 file_paths += self.collection_file_paths(item, path_prefix=new_prefix) 1012 return file_paths 1013 1014 def _lock_file(self, fileobj): 1015 try: 1016 fcntl.flock(fileobj, fcntl.LOCK_EX | fcntl.LOCK_NB) 1017 except IOError: 1018 raise ResumeCacheConflict(u"{} locked".format(fileobj.name)) 1019 1020 def _save_state(self): 1021 """ 1022 Atomically save current state into cache. 1023 """ 1024 with self._state_lock: 1025 # We're not using copy.deepcopy() here because it's a lot slower 1026 # than json.dumps(), and we're already needing JSON format to be 1027 # saved on disk. 1028 state = json.dumps(self._state) 1029 try: 1030 new_cache = tempfile.NamedTemporaryFile( 1031 mode='w+', 1032 dir=os.path.dirname(self._cache_filename), delete=False) 1033 self._lock_file(new_cache) 1034 new_cache.write(state) 1035 new_cache.flush() 1036 os.fsync(new_cache) 1037 os.rename(new_cache.name, self._cache_filename) 1038 except (IOError, OSError, ResumeCacheConflict) as error: 1039 self.logger.error("There was a problem while saving the cache file: {}".format(error)) 1040 try: 1041 os.unlink(new_cache_name) 1042 except NameError: # mkstemp failed. 1043 pass 1044 else: 1045 self._cache_file.close() 1046 self._cache_file = new_cache 1047 1048 def collection_name(self): 1049 return self._my_collection().api_response()['name'] if self._my_collection().api_response() else None 1050 1051 def collection_trash_at(self): 1052 return self._my_collection().get_trash_at() 1053 1054 def manifest_locator(self): 1055 return self._my_collection().manifest_locator() 1056 1057 def portable_data_hash(self): 1058 pdh = self._my_collection().portable_data_hash() 1059 m = self._my_collection().stripped_manifest().encode() 1060 local_pdh = '{}+{}'.format(hashlib.md5(m).hexdigest(), len(m)) 1061 if pdh != local_pdh: 1062 self.logger.warning("\n".join([ 1063 "arv-put: API server provided PDH differs from local manifest.", 1064 " This should not happen; showing API server version."])) 1065 return pdh 1066 1067 def manifest_text(self, stream_name=".", strip=False, normalize=False): 1068 return self._my_collection().manifest_text(stream_name, strip, normalize) 1069 1070 def _datablocks_on_item(self, item): 1071 """ 1072 Return a list of datablock locators, recursively navigating 1073 through subcollections 1074 """ 1075 if isinstance(item, arvados.arvfile.ArvadosFile): 1076 if item.size() == 0: 1077 # Empty file locator 1078 return ["d41d8cd98f00b204e9800998ecf8427e+0"] 1079 else: 1080 locators = [] 1081 for segment in item.segments(): 1082 loc = segment.locator 1083 locators.append(loc) 1084 return locators 1085 elif isinstance(item, arvados.collection.Collection): 1086 l = [self._datablocks_on_item(x) for x in item.values()] 1087 # Fast list flattener method taken from: 1088 # http://stackoverflow.com/questions/952914/making-a-flat-list-out-of-list-of-lists-in-python 1089 return [loc for sublist in l for loc in sublist] 1090 else: 1091 return None 1092 1093 def data_locators(self): 1094 with self._collection_lock: 1095 # Make sure all datablocks are flushed before getting the locators 1096 self._my_collection().manifest_text() 1097 datablocks = self._datablocks_on_item(self._my_collection()) 1098 return datablocks 1099 1100_machine_format = "{} {}: {{}} written {{}} total\n".format(sys.argv[0], 1101 os.getpid()) 1102 1103# Simulate glob.glob() matching behavior without the need to scan the filesystem 1104# Note: fnmatch() doesn't work correctly when used with pathnames. For example the 1105# pattern 'tests/*.py' will match 'tests/run_test.py' and also 'tests/subdir/run_test.py', 1106# so instead we're using it on every path component. 1107def pathname_match(pathname, pattern): 1108 name = pathname.split(os.sep) 1109 # Fix patterns like 'some/subdir/' or 'some//subdir' 1110 pat = [x for x in pattern.split(os.sep) if x != '' and x != '.'] 1111 if len(name) != len(pat): 1112 return False 1113 for i in range(len(name)): 1114 if not fnmatch.fnmatch(name[i], pat[i]): 1115 return False 1116 return True 1117 1118def machine_progress(bytes_written, bytes_expected): 1119 return _machine_format.format( 1120 bytes_written, -1 if (bytes_expected is None) else bytes_expected) 1121 1122def human_progress(bytes_written, bytes_expected): 1123 if bytes_expected: 1124 return "\r{}M / {}M {:.1%} ".format( 1125 bytes_written >> 20, bytes_expected >> 20, 1126 float(bytes_written) / bytes_expected) 1127 else: 1128 return "\r{} ".format(bytes_written) 1129 1130def progress_writer(progress_func, outfile=sys.stderr): 1131 def write_progress(bytes_written, bytes_expected): 1132 outfile.write(progress_func(bytes_written, bytes_expected)) 1133 return write_progress 1134 1135def desired_project_uuid(api_client, project_uuid, num_retries): 1136 if not project_uuid: 1137 query = api_client.users().current() 1138 elif arvados.util.user_uuid_pattern.match(project_uuid): 1139 query = api_client.users().get(uuid=project_uuid) 1140 elif arvados.util.group_uuid_pattern.match(project_uuid): 1141 query = api_client.groups().get(uuid=project_uuid) 1142 else: 1143 raise ValueError("Not a valid project UUID: {}".format(project_uuid)) 1144 return query.execute(num_retries=num_retries)['uuid'] 1145 1146def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr, 1147 install_sig_handlers=True): 1148 global api_client 1149 1150 args = parse_arguments(arguments) 1151 logger = logging.getLogger('arvados.arv_put') 1152 if args.silent: 1153 logger.setLevel(logging.WARNING) 1154 else: 1155 logger.setLevel(logging.INFO) 1156 status = 0 1157 1158 request_id = arvados.util.new_request_id() 1159 1160 formatter = ArvPutLogFormatter(request_id) 1161 logging.getLogger('arvados').handlers[0].setFormatter(formatter) 1162 1163 if api_client is None: 1164 api_client = arvados.api('v1', request_id=request_id, num_retries=args.retries) 1165 1166 if install_sig_handlers: 1167 arv_cmd.install_signal_handlers() 1168 1169 # Trash arguments validation 1170 trash_at = None 1171 if args.trash_at is not None: 1172 # ciso8601 considers YYYYMM as invalid but YYYY-MM as valid, so here we 1173 # make sure the user provides a complete YYYY-MM-DD date. 1174 if not re.match(r'^\d{4}(?P<dash>-?)\d{2}?(?P=dash)\d{2}', args.trash_at): 1175 logger.error("--trash-at argument format invalid, use --help to see examples.") 1176 sys.exit(1) 1177 # Check if no time information was provided. In that case, assume end-of-day. 1178 if re.match(r'^\d{4}(?P<dash>-?)\d{2}?(?P=dash)\d{2}$', args.trash_at): 1179 args.trash_at += 'T23:59:59' 1180 try: 1181 trash_at = ciso8601.parse_datetime(args.trash_at) 1182 except: 1183 logger.error("--trash-at argument format invalid, use --help to see examples.") 1184 sys.exit(1) 1185 else: 1186 if trash_at.tzinfo is not None: 1187 # Timezone aware datetime provided. 1188 utcoffset = -trash_at.utcoffset() 1189 else: 1190 # Timezone naive datetime provided. Assume is local. 1191 if time.daylight: 1192 utcoffset = datetime.timedelta(seconds=time.altzone) 1193 else: 1194 utcoffset = datetime.timedelta(seconds=time.timezone) 1195 # Convert to UTC timezone naive datetime. 1196 trash_at = trash_at.replace(tzinfo=None) + utcoffset 1197 1198 if trash_at <= datetime.datetime.utcnow(): 1199 logger.error("--trash-at argument must be set in the future") 1200 sys.exit(1) 1201 if args.trash_after is not None: 1202 if args.trash_after < 1: 1203 logger.error("--trash-after argument must be >= 1") 1204 sys.exit(1) 1205 trash_at = datetime.timedelta(seconds=(args.trash_after * 24 * 60 * 60)) 1206 1207 # Determine the name to use 1208 if args.name: 1209 if args.stream or args.raw: 1210 logger.error("Cannot use --name with --stream or --raw") 1211 sys.exit(1) 1212 elif args.update_collection: 1213 logger.error("Cannot use --name with --update-collection") 1214 sys.exit(1) 1215 collection_name = args.name 1216 else: 1217 collection_name = "Saved at {} by {}@{}".format( 1218 datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), 1219 pwd.getpwuid(os.getuid()).pw_name, 1220 socket.gethostname()) 1221 1222 if args.project_uuid and (args.stream or args.raw): 1223 logger.error("Cannot use --project-uuid with --stream or --raw") 1224 sys.exit(1) 1225 1226 # Determine the parent project 1227 try: 1228 project_uuid = desired_project_uuid(api_client, args.project_uuid, 1229 args.retries) 1230 except (apiclient_errors.Error, ValueError) as error: 1231 logger.error(error) 1232 sys.exit(1) 1233 1234 if args.progress: 1235 reporter = progress_writer(human_progress) 1236 elif args.batch_progress: 1237 reporter = progress_writer(machine_progress) 1238 else: 1239 reporter = None 1240 1241 # Setup exclude regex from all the --exclude arguments provided 1242 name_patterns = [] 1243 exclude_paths = [] 1244 exclude_names = None 1245 if len(args.exclude) > 0: 1246 # We're supporting 2 kinds of exclusion patterns: 1247 # 1) --exclude '*.jpg' (file/dir name patterns, will only match 1248 # the name, wherever the file is on the tree) 1249 # 2.1) --exclude 'foo/bar' (file/dir path patterns, will match the 1250 # entire path, and should be relative to 1251 # any input dir argument) 1252 # 2.2) --exclude './*.jpg' (Special case for excluding files/dirs 1253 # placed directly underneath the input dir) 1254 for p in args.exclude: 1255 # Only relative paths patterns allowed 1256 if p.startswith(os.sep): 1257 logger.error("Cannot use absolute paths with --exclude") 1258 sys.exit(1) 1259 if os.path.dirname(p): 1260 # We don't support of path patterns with '..' 1261 p_parts = p.split(os.sep) 1262 if '..' in p_parts: 1263 logger.error( 1264 "Cannot use path patterns that include or '..'") 1265 sys.exit(1) 1266 # Path search pattern 1267 exclude_paths.append(p) 1268 else: 1269 # Name-only search pattern 1270 name_patterns.append(p) 1271 # For name only matching, we can combine all patterns into a single 1272 # regexp, for better performance. 1273 exclude_names = re.compile('|'.join( 1274 [fnmatch.translate(p) for p in name_patterns] 1275 )) if len(name_patterns) > 0 else None 1276 # Show the user the patterns to be used, just in case they weren't 1277 # specified inside quotes and got changed by the shell expansion. 1278 logger.info("Exclude patterns: {}".format(args.exclude)) 1279 1280 # If this is used by a human, and there's at least one directory to be 1281 # uploaded, the expected bytes calculation can take a moment. 1282 if args.progress and any([os.path.isdir(f) for f in args.paths]): 1283 logger.info("Calculating upload size, this could take some time...") 1284 try: 1285 writer = ArvPutUploadJob(paths = args.paths, 1286 resume = args.resume, 1287 use_cache = args.use_cache, 1288 batch_mode= args.batch, 1289 filename = args.filename, 1290 reporter = reporter, 1291 api_client = api_client, 1292 num_retries = args.retries, 1293 replication_desired = args.replication, 1294 put_threads = args.threads, 1295 name = collection_name, 1296 owner_uuid = project_uuid, 1297 ensure_unique_name = True, 1298 update_collection = args.update_collection, 1299 storage_classes=args.storage_classes, 1300 logger=logger, 1301 dry_run=args.dry_run, 1302 follow_links=args.follow_links, 1303 exclude_paths=exclude_paths, 1304 exclude_names=exclude_names, 1305 trash_at=trash_at) 1306 except ResumeCacheConflict: 1307 logger.error("\n".join([ 1308 "arv-put: Another process is already uploading this data.", 1309 " Use --no-cache if this is really what you want."])) 1310 sys.exit(1) 1311 except ResumeCacheInvalidError: 1312 logger.error("\n".join([ 1313 "arv-put: Resume cache contains invalid signature: it may have expired", 1314 " or been created with another Arvados user's credentials.", 1315 " Switch user or use one of the following options to restart upload:", 1316 " --no-resume to start a new resume cache.", 1317 " --no-cache to disable resume cache.", 1318 " --batch to ignore the resume cache if invalid."])) 1319 sys.exit(1) 1320 except (CollectionUpdateError, PathDoesNotExistError) as error: 1321 logger.error("\n".join([ 1322 "arv-put: %s" % str(error)])) 1323 sys.exit(1) 1324 except ArvPutUploadIsPending: 1325 # Dry run check successful, return proper exit code. 1326 sys.exit(2) 1327 except ArvPutUploadNotPending: 1328 # No files pending for upload 1329 sys.exit(0) 1330 1331 if not args.dry_run and not args.update_collection and args.resume and writer.bytes_written > 0: 1332 logger.warning("\n".join([ 1333 "arv-put: Resuming previous upload from last checkpoint.", 1334 " Use the --no-resume option to start over."])) 1335 1336 if not args.dry_run: 1337 writer.report_progress() 1338 output = None 1339 try: 1340 writer.start(save_collection=not(args.stream or args.raw)) 1341 except (arvados.errors.ApiError, arvados.errors.KeepWriteError) as error: 1342 logger.error("\n".join([ 1343 "arv-put: %s" % str(error)])) 1344 sys.exit(1) 1345 1346 if args.progress: # Print newline to split stderr from stdout for humans. 1347 logger.info("\n") 1348 1349 if args.stream: 1350 if args.normalize: 1351 output = writer.manifest_text(normalize=True) 1352 else: 1353 output = writer.manifest_text() 1354 elif args.raw: 1355 output = ','.join(writer.data_locators()) 1356 elif writer.manifest_locator() is not None: 1357 try: 1358 expiration_notice = "" 1359 if writer.collection_trash_at() is not None: 1360 # Get the local timezone-naive version, and log it with timezone information. 1361 if time.daylight: 1362 local_trash_at = writer.collection_trash_at().replace(tzinfo=None) - datetime.timedelta(seconds=time.altzone) 1363 else: 1364 local_trash_at = writer.collection_trash_at().replace(tzinfo=None) - datetime.timedelta(seconds=time.timezone) 1365 expiration_notice = ". It will expire on {} {}.".format( 1366 local_trash_at.strftime("%Y-%m-%d %H:%M:%S"), time.strftime("%z")) 1367 if args.update_collection: 1368 logger.info(u"Collection updated: '{}'{}".format( 1369 writer.collection_name(), expiration_notice)) 1370 else: 1371 logger.info(u"Collection saved as '{}'{}".format( 1372 writer.collection_name(), expiration_notice)) 1373 if args.portable_data_hash: 1374 output = writer.portable_data_hash() 1375 else: 1376 output = writer.manifest_locator() 1377 except apiclient_errors.Error as error: 1378 logger.error( 1379 "arv-put: Error creating Collection on project: {}.".format( 1380 error)) 1381 status = 1 1382 else: 1383 status = 1 1384 1385 # Print the locator (uuid) of the new collection. 1386 if output is None: 1387 status = status or 1 1388 elif not args.silent: 1389 stdout.write(output) 1390 if not output.endswith('\n'): 1391 stdout.write('\n') 1392 1393 if install_sig_handlers: 1394 arv_cmd.restore_signal_handlers() 1395 1396 if status != 0: 1397 sys.exit(status) 1398 1399 # Success! 1400 return output 1401 1402 1403if __name__ == '__main__': 1404 main()
42def get_run_opts(): 43 """Returns an argparse.ArgumentParser instances that includes 44 a subset of parameters used both by this script ('arv put') and also reused 45 by 'arv keep docker'. 46 """ 47 run_opts = argparse.ArgumentParser(add_help=False) 48 49 run_opts.add_argument('--project-uuid', metavar='UUID', help=""" 50 Store the collection in the specified project, instead of your Home 51 project. 52 """) 53 54 run_opts.add_argument('--name', help=""" 55 Save the collection with the specified name. 56 """) 57 58 _group = run_opts.add_mutually_exclusive_group() 59 _group.add_argument('--progress', action='store_true', 60 help=""" 61 Display human-readable progress on stderr (bytes and, if possible, 62 percentage of total data size). This is the default behavior when 63 stderr is a tty. 64 """) 65 66 _group.add_argument('--no-progress', action='store_true', 67 help=""" 68 Do not display human-readable progress on stderr, even if stderr is a 69 tty. 70 """) 71 72 _group.add_argument('--batch-progress', action='store_true', 73 help=""" 74 Display machine-readable progress on stderr (bytes and, if known, 75 total data size). 76 """) 77 78 run_opts.add_argument('--silent', action='store_true', 79 help=""" 80 Do not print any debug messages to console. (Any error messages will 81 still be displayed.) 82 """) 83 84 run_opts.add_argument('--batch', action='store_true', default=False, 85 help=""" 86 Retries with '--no-resume --no-cache' if cached state contains invalid/expired 87 block signatures. 88 """) 89 90 _group = run_opts.add_mutually_exclusive_group() 91 _group.add_argument('--resume', action='store_true', default=True, 92 help=""" 93 Continue interrupted uploads from cached state (default). 94 """) 95 _group.add_argument('--no-resume', action='store_false', dest='resume', 96 help=""" 97 Do not continue interrupted uploads from cached state. 98 """) 99 100 _group = run_opts.add_mutually_exclusive_group() 101 _group.add_argument('--cache', action='store_true', dest='use_cache', default=True, 102 help=""" 103 Save upload state in a cache file for resuming (default). 104 """) 105 _group.add_argument('--no-cache', action='store_false', dest='use_cache', 106 help=""" 107 Do not save upload state in a cache file for resuming. 108 """) 109 110 return run_opts
Returns an argparse.ArgumentParser instances that includes a subset of parameters used both by this script (’arv put’) and also reused by ‘arv keep docker’.
113def get_argument_parser(): 114 """Returns an argparse.ArgumentParser instance, the main argument parser 115 used by 'arv put', given the shared argument parser instance. 116 """ 117 upload_opts = argparse.ArgumentParser(add_help=False) 118 119 upload_opts.add_argument('--version', action='version', 120 version=f"%(prog)s {__version__}", 121 help='Print version and exit.') 122 upload_opts.add_argument('paths', metavar='path', type=str, nargs='*', 123 help=""" 124 Local file or directory. If path is a directory reference with a trailing 125 slash, then just upload the directory's contents; otherwise upload the 126 directory itself. Default: read from standard input. 127 """) 128 129 _group = upload_opts.add_mutually_exclusive_group() 130 131 _group.add_argument('--max-manifest-depth', type=int, metavar='N', 132 default=-1, help=argparse.SUPPRESS) 133 134 _group.add_argument('--normalize', action='store_true', 135 help=""" 136 Normalize the manifest by re-ordering files and streams after writing 137 data. 138 """) 139 140 _group.add_argument('--dry-run', action='store_true', default=False, 141 help=""" 142 Don't actually upload files, but only check if any file should be 143 uploaded. Exit with code=2 when files are pending for upload. 144 """) 145 146 _group = upload_opts.add_mutually_exclusive_group() 147 148 _group.add_argument('--as-stream', action='store_true', dest='stream', 149 help=""" 150 Synonym for --stream. 151 """) 152 153 _group.add_argument('--stream', action='store_true', 154 help=""" 155 Store the file content and display the resulting manifest on 156 stdout. Do not save a Collection object in Arvados. 157 """) 158 159 _group.add_argument('--as-manifest', action='store_true', dest='manifest', 160 help=""" 161 Synonym for --manifest. 162 """) 163 164 _group.add_argument('--in-manifest', action='store_true', dest='manifest', 165 help=""" 166 Synonym for --manifest. 167 """) 168 169 _group.add_argument('--manifest', action='store_true', 170 help=""" 171 Store the file data and resulting manifest in Keep, save a Collection 172 object in Arvados, and display the manifest locator (Collection uuid) 173 on stdout. This is the default behavior. 174 """) 175 176 _group.add_argument('--as-raw', action='store_true', dest='raw', 177 help=""" 178 Synonym for --raw. 179 """) 180 181 _group.add_argument('--raw', action='store_true', 182 help=""" 183 Store the file content and display the data block locators on stdout, 184 separated by commas, with a trailing newline. Do not store a 185 manifest. 186 """) 187 188 upload_opts.add_argument('--update-collection', type=str, default=None, 189 dest='update_collection', metavar="UUID", help=""" 190 Update an existing collection identified by the given Arvados collection 191 UUID. All new local files will be uploaded. 192 """) 193 194 upload_opts.add_argument('--use-filename', type=str, default=None, 195 dest='filename', help=""" 196 Synonym for --filename. 197 """) 198 199 upload_opts.add_argument('--filename', type=str, default=None, 200 help=""" 201 Use the given filename in the manifest, instead of the name of the 202 local file. This is useful when "-" or "/dev/stdin" is given as an 203 input file. It can be used only if there is exactly one path given and 204 it is not a directory. Implies --manifest. 205 """) 206 207 upload_opts.add_argument('--portable-data-hash', action='store_true', 208 help=""" 209 Print the portable data hash instead of the Arvados UUID for the collection 210 created by the upload. 211 """) 212 213 upload_opts.add_argument('--replication', type=int, metavar='N', 214 default=None, 215 help=""" 216 Set the replication level for the new collection: how many different 217 physical storage devices (e.g., disks) should have a copy of each data 218 block. Default is to use the server-provided default (if any) or 2. 219 """) 220 221 upload_opts.add_argument( 222 '--storage-classes', 223 type=arv_cmd.UniqueSplit(), 224 help=""" 225 Specify comma separated list of storage classes to be used when saving data to Keep. 226 """) 227 228 upload_opts.add_argument('--threads', type=int, metavar='N', default=None, 229 help=""" 230 Set the number of upload threads to be used. Take into account that 231 using lots of threads will increase the RAM requirements. Default is 232 to use 2 threads. 233 On high latency installations, using a greater number will improve 234 overall throughput. 235 """) 236 237 upload_opts.add_argument('--exclude', metavar='PATTERN', default=[], 238 action='append', help=""" 239 Exclude files and directories whose names match the given glob pattern. When 240 using a path-like pattern like 'subdir/*.txt', all text files inside 'subdir' 241 directory, relative to the provided input dirs will be excluded. 242 When using a filename pattern like '*.txt', any text file will be excluded 243 no matter where it is placed. 244 For the special case of needing to exclude only files or dirs directly below 245 the given input directory, you can use a pattern like './exclude_this.gif'. 246 You can specify multiple patterns by using this argument more than once. 247 """) 248 249 _group = upload_opts.add_mutually_exclusive_group() 250 _group.add_argument('--follow-links', action='store_true', default=True, 251 dest='follow_links', help=""" 252 Follow file and directory symlinks (default). 253 """) 254 _group.add_argument('--no-follow-links', action='store_false', dest='follow_links', 255 help=""" 256 Ignore file and directory symlinks. Even paths given explicitly on the 257 command line will be skipped if they are symlinks. 258 """) 259 260 _group = upload_opts.add_mutually_exclusive_group() 261 _group.add_argument('--trash-at', metavar='YYYY-MM-DDTHH:MM', default=None, 262 help=""" 263 Set the trash date of the resulting collection to an absolute date in the future. 264 The accepted format is defined by the ISO 8601 standard. Examples: 20090103, 2009-01-03, 20090103T181505, 2009-01-03T18:15:05.\n 265 Timezone information can be added. If not, the provided date/time is assumed as being in the local system's timezone. 266 """) 267 _group.add_argument('--trash-after', type=int, metavar='DAYS', default=None, 268 help=""" 269 Set the trash date of the resulting collection to an amount of days from the 270 date/time that the upload process finishes. 271 """) 272 273 return argparse.ArgumentParser( 274 description='Copy data from the local filesystem to Keep.', 275 parents=[upload_opts, get_run_opts(), arv_cmd.retry_opt])
Returns an argparse.ArgumentParser instance, the main argument parser used by ‘arv put’, given the shared argument parser instance.
278def parse_arguments(arguments): 279 arg_parser = get_argument_parser() 280 args = arg_parser.parse_args(arguments) 281 282 if len(args.paths) == 0: 283 args.paths = ['-'] 284 285 args.paths = ["-" if x == "/dev/stdin" else x for x in args.paths] 286 287 if args.filename and (len(args.paths) != 1 or os.path.isdir(args.paths[0])): 288 arg_parser.error(""" 289 --filename argument cannot be used when storing a directory or 290 multiple files. 291 """) 292 293 # Turn on --progress by default if stderr is a tty. 294 if (not (args.batch_progress or args.no_progress or args.silent) 295 and sys.stderr.isatty()): 296 args.progress = True 297 298 # Turn off --resume (default) if --no-cache is used. 299 if not args.use_cache: 300 args.resume = False 301 302 if args.paths == ['-']: 303 if args.update_collection: 304 arg_parser.error(""" 305 --update-collection cannot be used when reading from stdin. 306 """) 307 args.resume = False 308 args.use_cache = False 309 if not args.filename: 310 args.filename = 'stdin' 311 312 # Remove possible duplicated patterns 313 if len(args.exclude) > 0: 314 args.exclude = list(set(args.exclude)) 315 316 return args
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
Common base class for all non-exit exceptions.
346class FileUploadList(list): 347 def __init__(self, dry_run=False): 348 list.__init__(self) 349 self.dry_run = dry_run 350 351 def append(self, other): 352 if self.dry_run: 353 raise ArvPutUploadIsPending() 354 super(FileUploadList, self).append(other)
Built-in mutable sequence.
If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.
358class ArvPutLogFormatter(logging.Formatter): 359 std_fmtr = logging.Formatter(arvados.log_format, arvados.log_date_format) 360 err_fmtr = None 361 request_id_informed = False 362 363 def __init__(self, request_id): 364 self.err_fmtr = logging.Formatter( 365 arvados.log_format+' (X-Request-Id: {})'.format(request_id), 366 arvados.log_date_format) 367 368 def format(self, record): 369 if (not self.request_id_informed) and (record.levelno in (logging.DEBUG, logging.ERROR)): 370 self.request_id_informed = True 371 return self.err_fmtr.format(record) 372 return self.std_fmtr.format(record)
Formatter instances are used to convert a LogRecord to text.
Formatters need to know how a LogRecord is constructed. They are responsible for converting a LogRecord to (usually) a string which can be interpreted by either a human or an external system. The base Formatter allows a formatting string to be specified. If none is supplied, the style-dependent default value, “%(message)s”, “{message}”, or “${message}”, is used.
The Formatter can be initialized with a format string which makes use of knowledge of the LogRecord attributes - e.g. the default value mentioned above makes use of the fact that the user’s message and arguments are pre- formatted into a LogRecord’s message attribute. Currently, the useful attributes in a LogRecord are described by:
%(name)s Name of the logger (logging channel) %(levelno)s Numeric logging level for the message (DEBUG, INFO, WARNING, ERROR, CRITICAL) %(levelname)s Text logging level for the message ("DEBUG”, “INFO”, “WARNING”, “ERROR”, “CRITICAL") %(pathname)s Full pathname of the source file where the logging call was issued (if available) %(filename)s Filename portion of pathname %(module)s Module (name portion of filename) %(lineno)d Source line number where the logging call was issued (if available) %(funcName)s Function name %(created)f Time when the LogRecord was created (time.time_ns() / 1e9 return value) %(asctime)s Textual time when the LogRecord was created %(msecs)d Millisecond portion of the creation time %(relativeCreated)d Time in milliseconds when the LogRecord was created, relative to the time the logging module was loaded (typically at application startup time) %(thread)d Thread ID (if available) %(threadName)s Thread name (if available) %(taskName)s Task name (if available) %(process)d Process ID (if available) %(processName)s Process name (if available) %(message)s The result of record.getMessage(), computed just as the record is emitted
363 def __init__(self, request_id): 364 self.err_fmtr = logging.Formatter( 365 arvados.log_format+' (X-Request-Id: {})'.format(request_id), 366 arvados.log_date_format)
Initialize the formatter with specified format strings.
Initialize the formatter either with the specified format string, or a default as described above. Allow for specialized date formatting with the optional datefmt argument. If datefmt is omitted, you get an ISO8601-like (or RFC 3339-like) format.
Use a style parameter of ‘%’, ‘{’ or ‘$’ to specify that you want to
use one of %-formatting, str.format() ({}) formatting or
string.Template formatting in your format string.
Changed in version 3.2:
Added the style parameter.
368 def format(self, record): 369 if (not self.request_id_informed) and (record.levelno in (logging.DEBUG, logging.ERROR)): 370 self.request_id_informed = True 371 return self.err_fmtr.format(record) 372 return self.std_fmtr.format(record)
Format the specified record as text.
The record’s attribute dictionary is used as the operand to a string formatting operation which yields the returned string. Before formatting the dictionary, a couple of preparatory steps are carried out. The message attribute of the record is computed using LogRecord.getMessage(). If the formatting string uses the time (as determined by a call to usesTime(), formatTime() is called to format the event time. If there is exception information, it is formatted using formatException() and appended to the message.
375class ResumeCache(object): 376 CACHE_DIR = 'arv-put' 377 378 def __init__(self, file_spec): 379 self.cache_file = open(file_spec, 'a+') 380 self._lock_file(self.cache_file) 381 self.filename = self.cache_file.name 382 383 @classmethod 384 def make_path(cls, args): 385 md5 = hashlib.md5() 386 md5.update(arvados.config.get('ARVADOS_API_HOST', '!nohost').encode()) 387 realpaths = sorted(os.path.realpath(path) for path in args.paths) 388 md5.update(b'\0'.join([p.encode() for p in realpaths])) 389 if any(os.path.isdir(path) for path in realpaths): 390 md5.update(b'-1') 391 elif args.filename: 392 md5.update(args.filename.encode()) 393 cache_path = Path(cls.CACHE_DIR) 394 if len(cache_path.parts) == 1: 395 cache_path = basedirs.BaseDirectories('CACHE').storage_path(cache_path) 396 else: 397 # Note this is a noop if cache_path is absolute, which is what we want. 398 cache_path = Path.home() / cache_path 399 cache_path.mkdir(parents=True, exist_ok=True, mode=0o700) 400 return str(cache_path / md5.hexdigest()) 401 402 def _lock_file(self, fileobj): 403 try: 404 fcntl.flock(fileobj, fcntl.LOCK_EX | fcntl.LOCK_NB) 405 except IOError: 406 raise ResumeCacheConflict(u"{} locked".format(fileobj.name)) 407 408 def load(self): 409 self.cache_file.seek(0) 410 return json.load(self.cache_file) 411 412 def check_cache(self, api_client=None, num_retries=0): 413 try: 414 state = self.load() 415 locator = None 416 try: 417 if "_finished_streams" in state and len(state["_finished_streams"]) > 0: 418 locator = state["_finished_streams"][0][1][0] 419 elif "_current_stream_locators" in state and len(state["_current_stream_locators"]) > 0: 420 locator = state["_current_stream_locators"][0] 421 if locator is not None: 422 kc = arvados.keep.KeepClient(api_client=api_client) 423 kc.head(locator, num_retries=num_retries) 424 except Exception as e: 425 self.restart() 426 except (ValueError): 427 pass 428 429 def save(self, data): 430 try: 431 new_cache_fd, new_cache_name = tempfile.mkstemp( 432 dir=os.path.dirname(self.filename)) 433 self._lock_file(new_cache_fd) 434 new_cache = os.fdopen(new_cache_fd, 'r+') 435 json.dump(data, new_cache) 436 os.rename(new_cache_name, self.filename) 437 except (IOError, OSError, ResumeCacheConflict): 438 try: 439 os.unlink(new_cache_name) 440 except NameError: # mkstemp failed. 441 pass 442 else: 443 self.cache_file.close() 444 self.cache_file = new_cache 445 446 def close(self): 447 self.cache_file.close() 448 449 def destroy(self): 450 try: 451 os.unlink(self.filename) 452 except OSError as error: 453 if error.errno != errno.ENOENT: # That's what we wanted anyway. 454 raise 455 self.close() 456 457 def restart(self): 458 self.destroy() 459 self.__init__(self.filename)
383 @classmethod 384 def make_path(cls, args): 385 md5 = hashlib.md5() 386 md5.update(arvados.config.get('ARVADOS_API_HOST', '!nohost').encode()) 387 realpaths = sorted(os.path.realpath(path) for path in args.paths) 388 md5.update(b'\0'.join([p.encode() for p in realpaths])) 389 if any(os.path.isdir(path) for path in realpaths): 390 md5.update(b'-1') 391 elif args.filename: 392 md5.update(args.filename.encode()) 393 cache_path = Path(cls.CACHE_DIR) 394 if len(cache_path.parts) == 1: 395 cache_path = basedirs.BaseDirectories('CACHE').storage_path(cache_path) 396 else: 397 # Note this is a noop if cache_path is absolute, which is what we want. 398 cache_path = Path.home() / cache_path 399 cache_path.mkdir(parents=True, exist_ok=True, mode=0o700) 400 return str(cache_path / md5.hexdigest())
412 def check_cache(self, api_client=None, num_retries=0): 413 try: 414 state = self.load() 415 locator = None 416 try: 417 if "_finished_streams" in state and len(state["_finished_streams"]) > 0: 418 locator = state["_finished_streams"][0][1][0] 419 elif "_current_stream_locators" in state and len(state["_current_stream_locators"]) > 0: 420 locator = state["_current_stream_locators"][0] 421 if locator is not None: 422 kc = arvados.keep.KeepClient(api_client=api_client) 423 kc.head(locator, num_retries=num_retries) 424 except Exception as e: 425 self.restart() 426 except (ValueError): 427 pass
429 def save(self, data): 430 try: 431 new_cache_fd, new_cache_name = tempfile.mkstemp( 432 dir=os.path.dirname(self.filename)) 433 self._lock_file(new_cache_fd) 434 new_cache = os.fdopen(new_cache_fd, 'r+') 435 json.dump(data, new_cache) 436 os.rename(new_cache_name, self.filename) 437 except (IOError, OSError, ResumeCacheConflict): 438 try: 439 os.unlink(new_cache_name) 440 except NameError: # mkstemp failed. 441 pass 442 else: 443 self.cache_file.close() 444 self.cache_file = new_cache
462class ArvPutUploadJob(object): 463 CACHE_DIR = 'arv-put' 464 EMPTY_STATE = { 465 'manifest' : None, # Last saved manifest checkpoint 466 'files' : {} # Previous run file list: {path : {size, mtime}} 467 } 468 469 def __init__(self, paths, resume=True, use_cache=True, reporter=None, 470 name=None, owner_uuid=None, api_client=None, batch_mode=False, 471 ensure_unique_name=False, num_retries=None, 472 put_threads=None, replication_desired=None, filename=None, 473 update_time=60.0, update_collection=None, storage_classes=None, 474 logger=logging.getLogger('arvados.arv_put'), dry_run=False, 475 follow_links=True, exclude_paths=[], exclude_names=None, 476 trash_at=None): 477 self.paths = paths 478 self.resume = resume 479 self.use_cache = use_cache 480 self.batch_mode = batch_mode 481 self.update = False 482 self.reporter = reporter 483 # This will set to 0 before start counting, if no special files are going 484 # to be read. 485 self.bytes_expected = None 486 self.bytes_written = 0 487 self.bytes_skipped = 0 488 self.name = name 489 self.owner_uuid = owner_uuid 490 self.ensure_unique_name = ensure_unique_name 491 self.num_retries = num_retries 492 self.replication_desired = replication_desired 493 self.put_threads = put_threads 494 self.filename = filename 495 self.storage_classes = storage_classes 496 self._api_client = api_client 497 self._state_lock = threading.Lock() 498 self._state = None # Previous run state (file list & manifest) 499 self._current_files = [] # Current run file list 500 self._cache_file = None 501 self._collection_lock = threading.Lock() 502 self._remote_collection = None # Collection being updated (if asked) 503 self._local_collection = None # Collection from previous run manifest 504 self._file_paths = set() # Files to be updated in remote collection 505 self._stop_checkpointer = threading.Event() 506 self._checkpointer = threading.Thread(target=self._update_task) 507 self._checkpointer.daemon = True 508 self._update_task_time = update_time # How many seconds wait between update runs 509 self._files_to_upload = FileUploadList(dry_run=dry_run) 510 self._upload_started = False 511 self.logger = logger 512 self.dry_run = dry_run 513 self._checkpoint_before_quit = True 514 self.follow_links = follow_links 515 self.exclude_paths = exclude_paths 516 self.exclude_names = exclude_names 517 self._trash_at = trash_at 518 519 if self._trash_at is not None: 520 if type(self._trash_at) not in [datetime.datetime, datetime.timedelta]: 521 raise TypeError('trash_at should be None, timezone-naive datetime or timedelta') 522 if type(self._trash_at) == datetime.datetime and self._trash_at.tzinfo is not None: 523 raise TypeError('provided trash_at datetime should be timezone-naive') 524 525 if not self.use_cache and self.resume: 526 raise ArvPutArgumentConflict('resume cannot be True when use_cache is False') 527 528 # Check for obvious dry-run responses 529 if self.dry_run and (not self.use_cache or not self.resume): 530 raise ArvPutUploadIsPending() 531 532 # Load cached data if any and if needed 533 self._setup_state(update_collection) 534 535 # Build the upload file list, excluding requested files and counting the 536 # bytes expected to be uploaded. 537 self._build_upload_list() 538 539 def _build_upload_list(self): 540 """ 541 Scan the requested paths to count file sizes, excluding requested files 542 and dirs and building the upload file list. 543 """ 544 # If there aren't special files to be read, reset total bytes count to zero 545 # to start counting. 546 if not any([p for p in self.paths 547 if not (os.path.isfile(p) or os.path.isdir(p))]): 548 self.bytes_expected = 0 549 550 for path in self.paths: 551 # Test for stdin first, in case some file named '-' exist 552 if path == '-': 553 if self.dry_run: 554 raise ArvPutUploadIsPending() 555 self._write_stdin(self.filename or 'stdin') 556 elif not os.path.exists(path): 557 raise PathDoesNotExistError(u"file or directory '{}' does not exist.".format(path)) 558 elif (not self.follow_links) and os.path.islink(path): 559 self.logger.warning("Skipping symlink '{}'".format(path)) 560 continue 561 elif os.path.isdir(path): 562 # Use absolute paths on cache index so CWD doesn't interfere 563 # with the caching logic. 564 orig_path = path 565 path = os.path.abspath(path) 566 if orig_path[-1:] == os.sep: 567 # When passing a directory reference with a trailing slash, 568 # its contents should be uploaded directly to the 569 # collection's root. 570 prefixdir = path 571 else: 572 # When passing a directory reference with no trailing slash, 573 # upload the directory to the collection's root. 574 prefixdir = os.path.dirname(path) 575 prefixdir += os.sep 576 for root, dirs, files in os.walk(path, 577 followlinks=self.follow_links): 578 root_relpath = os.path.relpath(root, path) 579 if root_relpath == '.': 580 root_relpath = '' 581 # Exclude files/dirs by full path matching pattern 582 if self.exclude_paths: 583 dirs[:] = [d for d in dirs 584 if not any(pathname_match( 585 os.path.join(root_relpath, d), pat) 586 for pat in self.exclude_paths)] 587 files = [f for f in files 588 if not any(pathname_match( 589 os.path.join(root_relpath, f), pat) 590 for pat in self.exclude_paths)] 591 # Exclude files/dirs by name matching pattern 592 if self.exclude_names is not None: 593 dirs[:] = [d for d in dirs 594 if not self.exclude_names.match(d)] 595 files = [f for f in files 596 if not self.exclude_names.match(f)] 597 # Make os.walk()'s dir traversing order deterministic 598 dirs.sort() 599 files.sort() 600 for f in files: 601 filepath = os.path.join(root, f) 602 if not os.path.isfile(filepath): 603 self.logger.warning("Skipping non-regular file '{}'".format(filepath)) 604 continue 605 # Add its size to the total bytes count (if applicable) 606 if self.follow_links or (not os.path.islink(filepath)): 607 if self.bytes_expected is not None: 608 self.bytes_expected += os.path.getsize(filepath) 609 self._check_file(filepath, 610 os.path.join(root[len(prefixdir):], f)) 611 else: 612 filepath = os.path.abspath(path) 613 # Add its size to the total bytes count (if applicable) 614 if self.follow_links or (not os.path.islink(filepath)): 615 if self.bytes_expected is not None: 616 self.bytes_expected += os.path.getsize(filepath) 617 self._check_file(filepath, 618 self.filename or os.path.basename(path)) 619 # If dry-mode is on, and got up to this point, then we should notify that 620 # there aren't any file to upload. 621 if self.dry_run: 622 raise ArvPutUploadNotPending() 623 # Remove local_collection's files that don't exist locally anymore, so the 624 # bytes_written count is correct. 625 for f in self.collection_file_paths(self._local_collection, 626 path_prefix=""): 627 if f != 'stdin' and f != self.filename and not f in self._file_paths: 628 self._local_collection.remove(f) 629 630 def start(self, save_collection): 631 """ 632 Start supporting thread & file uploading 633 """ 634 self._checkpointer.start() 635 try: 636 # Update bytes_written from current local collection and 637 # report initial progress. 638 self._update() 639 # Actual file upload 640 self._upload_started = True # Used by the update thread to start checkpointing 641 self._upload_files() 642 except (SystemExit, Exception) as e: 643 self._checkpoint_before_quit = False 644 # Log stack trace only when Ctrl-C isn't pressed (SIGINT) 645 # Note: We're expecting SystemExit instead of 646 # KeyboardInterrupt because we have a custom signal 647 # handler in place that raises SystemExit with the catched 648 # signal's code. 649 if isinstance(e, PathDoesNotExistError): 650 # We aren't interested in the traceback for this case 651 pass 652 elif not isinstance(e, SystemExit) or e.code != -2: 653 self.logger.warning("Abnormal termination:\n{}".format( 654 traceback.format_exc())) 655 raise 656 finally: 657 if not self.dry_run: 658 # Stop the thread before doing anything else 659 self._stop_checkpointer.set() 660 self._checkpointer.join() 661 if self._checkpoint_before_quit: 662 # Commit all pending blocks & one last _update() 663 self._local_collection.manifest_text() 664 self._update(final=True) 665 if save_collection: 666 self.save_collection() 667 if self.use_cache: 668 self._cache_file.close() 669 670 def _collection_trash_at(self): 671 """ 672 Returns the trash date that the collection should use at save time. 673 Takes into account absolute/relative trash_at values requested 674 by the user. 675 """ 676 if type(self._trash_at) == datetime.timedelta: 677 # Get an absolute datetime for trash_at 678 return datetime.datetime.utcnow() + self._trash_at 679 return self._trash_at 680 681 def save_collection(self): 682 if self.update: 683 # Check if files should be updated on the remote collection. 684 for fp in self._file_paths: 685 remote_file = self._remote_collection.find(fp) 686 if not remote_file: 687 # File don't exist on remote collection, copy it. 688 self._remote_collection.copy(fp, fp, self._local_collection) 689 elif remote_file != self._local_collection.find(fp): 690 # A different file exist on remote collection, overwrite it. 691 self._remote_collection.copy(fp, fp, self._local_collection, overwrite=True) 692 else: 693 # The file already exist on remote collection, skip it. 694 pass 695 self._remote_collection.save(num_retries=self.num_retries, 696 trash_at=self._collection_trash_at()) 697 else: 698 if len(self._local_collection) == 0: 699 self.logger.warning("No files were uploaded, skipping collection creation.") 700 return 701 self._local_collection.save_new( 702 name=self.name, owner_uuid=self.owner_uuid, 703 ensure_unique_name=self.ensure_unique_name, 704 num_retries=self.num_retries, 705 trash_at=self._collection_trash_at()) 706 707 def destroy_cache(self): 708 if self.use_cache: 709 try: 710 os.unlink(self._cache_filename) 711 except OSError as error: 712 # That's what we wanted anyway. 713 if error.errno != errno.ENOENT: 714 raise 715 self._cache_file.close() 716 717 def _collection_size(self, collection): 718 """ 719 Recursively get the total size of the collection 720 """ 721 size = 0 722 for item in collection.values(): 723 if isinstance(item, arvados.collection.Collection) or isinstance(item, arvados.collection.Subcollection): 724 size += self._collection_size(item) 725 else: 726 size += item.size() 727 return size 728 729 def _update_task(self): 730 """ 731 Periodically called support task. File uploading is 732 asynchronous so we poll status from the collection. 733 """ 734 while not self._stop_checkpointer.wait(1 if not self._upload_started else self._update_task_time): 735 self._update() 736 737 def _update(self, final=False): 738 """ 739 Update cached manifest text and report progress. 740 """ 741 if self._upload_started: 742 with self._collection_lock: 743 self.bytes_written = self._collection_size(self._local_collection) 744 if self.use_cache: 745 if final: 746 manifest = self._local_collection.manifest_text() 747 else: 748 # Get the manifest text without comitting pending blocks 749 manifest = self._local_collection.manifest_text(strip=False, 750 normalize=False, 751 only_committed=True) 752 # Update cache 753 with self._state_lock: 754 self._state['manifest'] = manifest 755 if self.use_cache: 756 try: 757 self._save_state() 758 except Exception as e: 759 self.logger.error("Unexpected error trying to save cache file: {}".format(e)) 760 # Keep remote collection's trash_at attribute synced when using relative expire dates 761 if self._remote_collection is not None and type(self._trash_at) == datetime.timedelta: 762 try: 763 self._api_client.collections().update( 764 uuid=self._remote_collection.manifest_locator(), 765 body={'trash_at': self._collection_trash_at().strftime("%Y-%m-%dT%H:%M:%S.%fZ")} 766 ).execute(num_retries=self.num_retries) 767 except Exception as e: 768 self.logger.error("Unexpected error trying to update remote collection's expire date: {}".format(e)) 769 else: 770 self.bytes_written = self.bytes_skipped 771 # Call the reporter, if any 772 self.report_progress() 773 774 def report_progress(self): 775 if self.reporter is not None: 776 self.reporter(self.bytes_written, self.bytes_expected) 777 778 def _write_stdin(self, filename): 779 output = self._local_collection.open(filename, 'wb') 780 self._write(sys.stdin.buffer, output) 781 output.close() 782 783 def _check_file(self, source, filename): 784 """ 785 Check if this file needs to be uploaded 786 """ 787 # Ignore symlinks when requested 788 if (not self.follow_links) and os.path.islink(source): 789 return 790 resume_offset = 0 791 should_upload = False 792 new_file_in_cache = False 793 # Record file path for updating the remote collection before exiting 794 self._file_paths.add(filename) 795 796 with self._state_lock: 797 # If no previous cached data on this file, store it for an eventual 798 # repeated run. 799 if source not in self._state['files']: 800 self._state['files'][source] = { 801 'mtime': os.path.getmtime(source), 802 'size' : os.path.getsize(source) 803 } 804 new_file_in_cache = True 805 cached_file_data = self._state['files'][source] 806 807 # Check if file was already uploaded (at least partially) 808 file_in_local_collection = self._local_collection.find(filename) 809 810 # If not resuming, upload the full file. 811 if not self.resume: 812 should_upload = True 813 # New file detected from last run, upload it. 814 elif new_file_in_cache: 815 should_upload = True 816 # Local file didn't change from last run. 817 elif cached_file_data['mtime'] == os.path.getmtime(source) and cached_file_data['size'] == os.path.getsize(source): 818 if not file_in_local_collection: 819 # File not uploaded yet, upload it completely 820 should_upload = True 821 elif file_in_local_collection.permission_expired(): 822 # Permission token expired, re-upload file. This will change whenever 823 # we have a API for refreshing tokens. 824 self.logger.warning(u"Uploaded file '{}' access token expired, will re-upload it from scratch".format(filename)) 825 should_upload = True 826 self._local_collection.remove(filename) 827 elif cached_file_data['size'] == file_in_local_collection.size(): 828 # File already there, skip it. 829 self.bytes_skipped += cached_file_data['size'] 830 elif cached_file_data['size'] > file_in_local_collection.size(): 831 # File partially uploaded, resume! 832 resume_offset = file_in_local_collection.size() 833 self.bytes_skipped += resume_offset 834 should_upload = True 835 else: 836 # Inconsistent cache, re-upload the file 837 should_upload = True 838 self._local_collection.remove(filename) 839 self.logger.warning(u"Uploaded version of file '{}' is bigger than local version, will re-upload it from scratch.".format(source)) 840 # Local file differs from cached data, re-upload it. 841 else: 842 if file_in_local_collection: 843 self._local_collection.remove(filename) 844 should_upload = True 845 846 if should_upload: 847 try: 848 self._files_to_upload.append((source, resume_offset, filename)) 849 except ArvPutUploadIsPending: 850 # This could happen when running on dry-mode, close cache file to 851 # avoid locking issues. 852 self._cache_file.close() 853 raise 854 855 def _upload_files(self): 856 for source, resume_offset, filename in self._files_to_upload: 857 with open(source, 'rb') as source_fd: 858 with self._state_lock: 859 self._state['files'][source]['mtime'] = os.path.getmtime(source) 860 self._state['files'][source]['size'] = os.path.getsize(source) 861 if resume_offset > 0: 862 # Start upload where we left off 863 output = self._local_collection.open(filename, 'ab') 864 source_fd.seek(resume_offset) 865 else: 866 # Start from scratch 867 output = self._local_collection.open(filename, 'wb') 868 self._write(source_fd, output) 869 output.close(flush=False) 870 871 def _write(self, source_fd, output): 872 while True: 873 data = source_fd.read(arvados.config.KEEP_BLOCK_SIZE) 874 if not data: 875 break 876 output.write(data) 877 878 def _my_collection(self): 879 return self._remote_collection if self.update else self._local_collection 880 881 def _get_cache_filepath(self): 882 # Set up cache file name from input paths. 883 md5 = hashlib.md5() 884 md5.update(arvados.config.get('ARVADOS_API_HOST', '!nohost').encode()) 885 realpaths = sorted(os.path.realpath(path) for path in self.paths) 886 md5.update(b'\0'.join([p.encode() for p in realpaths])) 887 if self.filename: 888 md5.update(self.filename.encode()) 889 cache_path = Path(self.CACHE_DIR) 890 if len(cache_path.parts) == 1: 891 cache_path = basedirs.BaseDirectories('CACHE').storage_path(cache_path) 892 else: 893 # Note this is a noop if cache_path is absolute, which is what we want. 894 cache_path = Path.home() / cache_path 895 cache_path.mkdir(parents=True, exist_ok=True, mode=0o700) 896 return str(cache_path / md5.hexdigest()) 897 898 def _setup_state(self, update_collection): 899 """ 900 Create a new cache file or load a previously existing one. 901 """ 902 # Load an already existing collection for update 903 if update_collection and re.match(arvados.util.collection_uuid_pattern, 904 update_collection): 905 try: 906 self._remote_collection = arvados.collection.Collection( 907 update_collection, 908 api_client=self._api_client, 909 storage_classes_desired=self.storage_classes, 910 num_retries=self.num_retries) 911 except arvados.errors.ApiError as error: 912 raise CollectionUpdateError("Cannot read collection {} ({})".format(update_collection, error)) 913 else: 914 self.update = True 915 elif update_collection: 916 # Collection locator provided, but unknown format 917 raise CollectionUpdateError("Collection locator unknown: '{}'".format(update_collection)) 918 919 if self.use_cache: 920 cache_filepath = self._get_cache_filepath() 921 if self.resume and os.path.exists(cache_filepath): 922 self.logger.info(u"Resuming upload from cache file {}".format(cache_filepath)) 923 self._cache_file = open(cache_filepath, 'a+') 924 else: 925 # --no-resume means start with a empty cache file. 926 self.logger.info(u"Creating new cache file at {}".format(cache_filepath)) 927 self._cache_file = open(cache_filepath, 'w+') 928 self._cache_filename = self._cache_file.name 929 self._lock_file(self._cache_file) 930 self._cache_file.seek(0) 931 932 with self._state_lock: 933 if self.use_cache: 934 try: 935 self._state = json.load(self._cache_file) 936 if not set(['manifest', 'files']).issubset(set(self._state.keys())): 937 # Cache at least partially incomplete, set up new cache 938 self._state = copy.deepcopy(self.EMPTY_STATE) 939 except ValueError: 940 # Cache file empty, set up new cache 941 self._state = copy.deepcopy(self.EMPTY_STATE) 942 else: 943 self.logger.info("No cache usage requested for this run.") 944 # No cache file, set empty state 945 self._state = copy.deepcopy(self.EMPTY_STATE) 946 if not self._cached_manifest_valid(): 947 if not self.batch_mode: 948 raise ResumeCacheInvalidError() 949 else: 950 self.logger.info("Invalid signatures on cache file '{}' while being run in 'batch mode' -- continuing anyways.".format(self._cache_file.name)) 951 self.use_cache = False # Don't overwrite preexisting cache file. 952 self._state = copy.deepcopy(self.EMPTY_STATE) 953 # Load the previous manifest so we can check if files were modified remotely. 954 self._local_collection = arvados.collection.Collection( 955 self._state['manifest'], 956 replication_desired=self.replication_desired, 957 storage_classes_desired=self.storage_classes, 958 put_threads=self.put_threads, 959 api_client=self._api_client, 960 num_retries=self.num_retries) 961 962 def _cached_manifest_valid(self): 963 """ 964 Validate the oldest non-expired block signature to check if cached manifest 965 is usable: checking if the cached manifest was not created with a different 966 arvados account. 967 """ 968 if self._state.get('manifest', None) is None: 969 # No cached manifest yet, all good. 970 return True 971 now = datetime.datetime.utcnow() 972 oldest_exp = None 973 oldest_loc = None 974 block_found = False 975 for m in arvados.util.keep_locator_pattern.finditer(self._state['manifest']): 976 loc = m.group(0) 977 try: 978 exp = datetime.datetime.utcfromtimestamp(int(loc.split('@')[1], 16)) 979 except IndexError: 980 # Locator without signature 981 continue 982 block_found = True 983 if exp > now and (oldest_exp is None or exp < oldest_exp): 984 oldest_exp = exp 985 oldest_loc = loc 986 if not block_found: 987 # No block signatures found => no invalid block signatures. 988 return True 989 if oldest_loc is None: 990 # Locator signatures found, but all have expired. 991 # Reset the cache and move on. 992 self.logger.info('Cache expired, starting from scratch.') 993 self._state['manifest'] = '' 994 return True 995 kc = arvados.KeepClient(api_client=self._api_client, 996 num_retries=self.num_retries) 997 try: 998 kc.head(oldest_loc) 999 except arvados.errors.KeepRequestError: 1000 # Something is wrong, cached manifest is not valid. 1001 return False 1002 return True 1003 1004 def collection_file_paths(self, col, path_prefix='.'): 1005 """Return a list of file paths by recursively go through the entire collection `col`""" 1006 file_paths = [] 1007 for name, item in col.items(): 1008 if isinstance(item, arvados.arvfile.ArvadosFile): 1009 file_paths.append(os.path.join(path_prefix, name)) 1010 elif isinstance(item, arvados.collection.Subcollection): 1011 new_prefix = os.path.join(path_prefix, name) 1012 file_paths += self.collection_file_paths(item, path_prefix=new_prefix) 1013 return file_paths 1014 1015 def _lock_file(self, fileobj): 1016 try: 1017 fcntl.flock(fileobj, fcntl.LOCK_EX | fcntl.LOCK_NB) 1018 except IOError: 1019 raise ResumeCacheConflict(u"{} locked".format(fileobj.name)) 1020 1021 def _save_state(self): 1022 """ 1023 Atomically save current state into cache. 1024 """ 1025 with self._state_lock: 1026 # We're not using copy.deepcopy() here because it's a lot slower 1027 # than json.dumps(), and we're already needing JSON format to be 1028 # saved on disk. 1029 state = json.dumps(self._state) 1030 try: 1031 new_cache = tempfile.NamedTemporaryFile( 1032 mode='w+', 1033 dir=os.path.dirname(self._cache_filename), delete=False) 1034 self._lock_file(new_cache) 1035 new_cache.write(state) 1036 new_cache.flush() 1037 os.fsync(new_cache) 1038 os.rename(new_cache.name, self._cache_filename) 1039 except (IOError, OSError, ResumeCacheConflict) as error: 1040 self.logger.error("There was a problem while saving the cache file: {}".format(error)) 1041 try: 1042 os.unlink(new_cache_name) 1043 except NameError: # mkstemp failed. 1044 pass 1045 else: 1046 self._cache_file.close() 1047 self._cache_file = new_cache 1048 1049 def collection_name(self): 1050 return self._my_collection().api_response()['name'] if self._my_collection().api_response() else None 1051 1052 def collection_trash_at(self): 1053 return self._my_collection().get_trash_at() 1054 1055 def manifest_locator(self): 1056 return self._my_collection().manifest_locator() 1057 1058 def portable_data_hash(self): 1059 pdh = self._my_collection().portable_data_hash() 1060 m = self._my_collection().stripped_manifest().encode() 1061 local_pdh = '{}+{}'.format(hashlib.md5(m).hexdigest(), len(m)) 1062 if pdh != local_pdh: 1063 self.logger.warning("\n".join([ 1064 "arv-put: API server provided PDH differs from local manifest.", 1065 " This should not happen; showing API server version."])) 1066 return pdh 1067 1068 def manifest_text(self, stream_name=".", strip=False, normalize=False): 1069 return self._my_collection().manifest_text(stream_name, strip, normalize) 1070 1071 def _datablocks_on_item(self, item): 1072 """ 1073 Return a list of datablock locators, recursively navigating 1074 through subcollections 1075 """ 1076 if isinstance(item, arvados.arvfile.ArvadosFile): 1077 if item.size() == 0: 1078 # Empty file locator 1079 return ["d41d8cd98f00b204e9800998ecf8427e+0"] 1080 else: 1081 locators = [] 1082 for segment in item.segments(): 1083 loc = segment.locator 1084 locators.append(loc) 1085 return locators 1086 elif isinstance(item, arvados.collection.Collection): 1087 l = [self._datablocks_on_item(x) for x in item.values()] 1088 # Fast list flattener method taken from: 1089 # http://stackoverflow.com/questions/952914/making-a-flat-list-out-of-list-of-lists-in-python 1090 return [loc for sublist in l for loc in sublist] 1091 else: 1092 return None 1093 1094 def data_locators(self): 1095 with self._collection_lock: 1096 # Make sure all datablocks are flushed before getting the locators 1097 self._my_collection().manifest_text() 1098 datablocks = self._datablocks_on_item(self._my_collection()) 1099 return datablocks
469 def __init__(self, paths, resume=True, use_cache=True, reporter=None, 470 name=None, owner_uuid=None, api_client=None, batch_mode=False, 471 ensure_unique_name=False, num_retries=None, 472 put_threads=None, replication_desired=None, filename=None, 473 update_time=60.0, update_collection=None, storage_classes=None, 474 logger=logging.getLogger('arvados.arv_put'), dry_run=False, 475 follow_links=True, exclude_paths=[], exclude_names=None, 476 trash_at=None): 477 self.paths = paths 478 self.resume = resume 479 self.use_cache = use_cache 480 self.batch_mode = batch_mode 481 self.update = False 482 self.reporter = reporter 483 # This will set to 0 before start counting, if no special files are going 484 # to be read. 485 self.bytes_expected = None 486 self.bytes_written = 0 487 self.bytes_skipped = 0 488 self.name = name 489 self.owner_uuid = owner_uuid 490 self.ensure_unique_name = ensure_unique_name 491 self.num_retries = num_retries 492 self.replication_desired = replication_desired 493 self.put_threads = put_threads 494 self.filename = filename 495 self.storage_classes = storage_classes 496 self._api_client = api_client 497 self._state_lock = threading.Lock() 498 self._state = None # Previous run state (file list & manifest) 499 self._current_files = [] # Current run file list 500 self._cache_file = None 501 self._collection_lock = threading.Lock() 502 self._remote_collection = None # Collection being updated (if asked) 503 self._local_collection = None # Collection from previous run manifest 504 self._file_paths = set() # Files to be updated in remote collection 505 self._stop_checkpointer = threading.Event() 506 self._checkpointer = threading.Thread(target=self._update_task) 507 self._checkpointer.daemon = True 508 self._update_task_time = update_time # How many seconds wait between update runs 509 self._files_to_upload = FileUploadList(dry_run=dry_run) 510 self._upload_started = False 511 self.logger = logger 512 self.dry_run = dry_run 513 self._checkpoint_before_quit = True 514 self.follow_links = follow_links 515 self.exclude_paths = exclude_paths 516 self.exclude_names = exclude_names 517 self._trash_at = trash_at 518 519 if self._trash_at is not None: 520 if type(self._trash_at) not in [datetime.datetime, datetime.timedelta]: 521 raise TypeError('trash_at should be None, timezone-naive datetime or timedelta') 522 if type(self._trash_at) == datetime.datetime and self._trash_at.tzinfo is not None: 523 raise TypeError('provided trash_at datetime should be timezone-naive') 524 525 if not self.use_cache and self.resume: 526 raise ArvPutArgumentConflict('resume cannot be True when use_cache is False') 527 528 # Check for obvious dry-run responses 529 if self.dry_run and (not self.use_cache or not self.resume): 530 raise ArvPutUploadIsPending() 531 532 # Load cached data if any and if needed 533 self._setup_state(update_collection) 534 535 # Build the upload file list, excluding requested files and counting the 536 # bytes expected to be uploaded. 537 self._build_upload_list()
630 def start(self, save_collection): 631 """ 632 Start supporting thread & file uploading 633 """ 634 self._checkpointer.start() 635 try: 636 # Update bytes_written from current local collection and 637 # report initial progress. 638 self._update() 639 # Actual file upload 640 self._upload_started = True # Used by the update thread to start checkpointing 641 self._upload_files() 642 except (SystemExit, Exception) as e: 643 self._checkpoint_before_quit = False 644 # Log stack trace only when Ctrl-C isn't pressed (SIGINT) 645 # Note: We're expecting SystemExit instead of 646 # KeyboardInterrupt because we have a custom signal 647 # handler in place that raises SystemExit with the catched 648 # signal's code. 649 if isinstance(e, PathDoesNotExistError): 650 # We aren't interested in the traceback for this case 651 pass 652 elif not isinstance(e, SystemExit) or e.code != -2: 653 self.logger.warning("Abnormal termination:\n{}".format( 654 traceback.format_exc())) 655 raise 656 finally: 657 if not self.dry_run: 658 # Stop the thread before doing anything else 659 self._stop_checkpointer.set() 660 self._checkpointer.join() 661 if self._checkpoint_before_quit: 662 # Commit all pending blocks & one last _update() 663 self._local_collection.manifest_text() 664 self._update(final=True) 665 if save_collection: 666 self.save_collection() 667 if self.use_cache: 668 self._cache_file.close()
Start supporting thread & file uploading
681 def save_collection(self): 682 if self.update: 683 # Check if files should be updated on the remote collection. 684 for fp in self._file_paths: 685 remote_file = self._remote_collection.find(fp) 686 if not remote_file: 687 # File don't exist on remote collection, copy it. 688 self._remote_collection.copy(fp, fp, self._local_collection) 689 elif remote_file != self._local_collection.find(fp): 690 # A different file exist on remote collection, overwrite it. 691 self._remote_collection.copy(fp, fp, self._local_collection, overwrite=True) 692 else: 693 # The file already exist on remote collection, skip it. 694 pass 695 self._remote_collection.save(num_retries=self.num_retries, 696 trash_at=self._collection_trash_at()) 697 else: 698 if len(self._local_collection) == 0: 699 self.logger.warning("No files were uploaded, skipping collection creation.") 700 return 701 self._local_collection.save_new( 702 name=self.name, owner_uuid=self.owner_uuid, 703 ensure_unique_name=self.ensure_unique_name, 704 num_retries=self.num_retries, 705 trash_at=self._collection_trash_at())
1004 def collection_file_paths(self, col, path_prefix='.'): 1005 """Return a list of file paths by recursively go through the entire collection `col`""" 1006 file_paths = [] 1007 for name, item in col.items(): 1008 if isinstance(item, arvados.arvfile.ArvadosFile): 1009 file_paths.append(os.path.join(path_prefix, name)) 1010 elif isinstance(item, arvados.collection.Subcollection): 1011 new_prefix = os.path.join(path_prefix, name) 1012 file_paths += self.collection_file_paths(item, path_prefix=new_prefix) 1013 return file_paths
Return a list of file paths by recursively go through the entire collection col
1058 def portable_data_hash(self): 1059 pdh = self._my_collection().portable_data_hash() 1060 m = self._my_collection().stripped_manifest().encode() 1061 local_pdh = '{}+{}'.format(hashlib.md5(m).hexdigest(), len(m)) 1062 if pdh != local_pdh: 1063 self.logger.warning("\n".join([ 1064 "arv-put: API server provided PDH differs from local manifest.", 1065 " This should not happen; showing API server version."])) 1066 return pdh
1108def pathname_match(pathname, pattern): 1109 name = pathname.split(os.sep) 1110 # Fix patterns like 'some/subdir/' or 'some//subdir' 1111 pat = [x for x in pattern.split(os.sep) if x != '' and x != '.'] 1112 if len(name) != len(pat): 1113 return False 1114 for i in range(len(name)): 1115 if not fnmatch.fnmatch(name[i], pat[i]): 1116 return False 1117 return True
1136def desired_project_uuid(api_client, project_uuid, num_retries): 1137 if not project_uuid: 1138 query = api_client.users().current() 1139 elif arvados.util.user_uuid_pattern.match(project_uuid): 1140 query = api_client.users().get(uuid=project_uuid) 1141 elif arvados.util.group_uuid_pattern.match(project_uuid): 1142 query = api_client.groups().get(uuid=project_uuid) 1143 else: 1144 raise ValueError("Not a valid project UUID: {}".format(project_uuid)) 1145 return query.execute(num_retries=num_retries)['uuid']
1147def main(arguments=None, stdout=sys.stdout, stderr=sys.stderr, 1148 install_sig_handlers=True): 1149 global api_client 1150 1151 args = parse_arguments(arguments) 1152 logger = logging.getLogger('arvados.arv_put') 1153 if args.silent: 1154 logger.setLevel(logging.WARNING) 1155 else: 1156 logger.setLevel(logging.INFO) 1157 status = 0 1158 1159 request_id = arvados.util.new_request_id() 1160 1161 formatter = ArvPutLogFormatter(request_id) 1162 logging.getLogger('arvados').handlers[0].setFormatter(formatter) 1163 1164 if api_client is None: 1165 api_client = arvados.api('v1', request_id=request_id, num_retries=args.retries) 1166 1167 if install_sig_handlers: 1168 arv_cmd.install_signal_handlers() 1169 1170 # Trash arguments validation 1171 trash_at = None 1172 if args.trash_at is not None: 1173 # ciso8601 considers YYYYMM as invalid but YYYY-MM as valid, so here we 1174 # make sure the user provides a complete YYYY-MM-DD date. 1175 if not re.match(r'^\d{4}(?P<dash>-?)\d{2}?(?P=dash)\d{2}', args.trash_at): 1176 logger.error("--trash-at argument format invalid, use --help to see examples.") 1177 sys.exit(1) 1178 # Check if no time information was provided. In that case, assume end-of-day. 1179 if re.match(r'^\d{4}(?P<dash>-?)\d{2}?(?P=dash)\d{2}$', args.trash_at): 1180 args.trash_at += 'T23:59:59' 1181 try: 1182 trash_at = ciso8601.parse_datetime(args.trash_at) 1183 except: 1184 logger.error("--trash-at argument format invalid, use --help to see examples.") 1185 sys.exit(1) 1186 else: 1187 if trash_at.tzinfo is not None: 1188 # Timezone aware datetime provided. 1189 utcoffset = -trash_at.utcoffset() 1190 else: 1191 # Timezone naive datetime provided. Assume is local. 1192 if time.daylight: 1193 utcoffset = datetime.timedelta(seconds=time.altzone) 1194 else: 1195 utcoffset = datetime.timedelta(seconds=time.timezone) 1196 # Convert to UTC timezone naive datetime. 1197 trash_at = trash_at.replace(tzinfo=None) + utcoffset 1198 1199 if trash_at <= datetime.datetime.utcnow(): 1200 logger.error("--trash-at argument must be set in the future") 1201 sys.exit(1) 1202 if args.trash_after is not None: 1203 if args.trash_after < 1: 1204 logger.error("--trash-after argument must be >= 1") 1205 sys.exit(1) 1206 trash_at = datetime.timedelta(seconds=(args.trash_after * 24 * 60 * 60)) 1207 1208 # Determine the name to use 1209 if args.name: 1210 if args.stream or args.raw: 1211 logger.error("Cannot use --name with --stream or --raw") 1212 sys.exit(1) 1213 elif args.update_collection: 1214 logger.error("Cannot use --name with --update-collection") 1215 sys.exit(1) 1216 collection_name = args.name 1217 else: 1218 collection_name = "Saved at {} by {}@{}".format( 1219 datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), 1220 pwd.getpwuid(os.getuid()).pw_name, 1221 socket.gethostname()) 1222 1223 if args.project_uuid and (args.stream or args.raw): 1224 logger.error("Cannot use --project-uuid with --stream or --raw") 1225 sys.exit(1) 1226 1227 # Determine the parent project 1228 try: 1229 project_uuid = desired_project_uuid(api_client, args.project_uuid, 1230 args.retries) 1231 except (apiclient_errors.Error, ValueError) as error: 1232 logger.error(error) 1233 sys.exit(1) 1234 1235 if args.progress: 1236 reporter = progress_writer(human_progress) 1237 elif args.batch_progress: 1238 reporter = progress_writer(machine_progress) 1239 else: 1240 reporter = None 1241 1242 # Setup exclude regex from all the --exclude arguments provided 1243 name_patterns = [] 1244 exclude_paths = [] 1245 exclude_names = None 1246 if len(args.exclude) > 0: 1247 # We're supporting 2 kinds of exclusion patterns: 1248 # 1) --exclude '*.jpg' (file/dir name patterns, will only match 1249 # the name, wherever the file is on the tree) 1250 # 2.1) --exclude 'foo/bar' (file/dir path patterns, will match the 1251 # entire path, and should be relative to 1252 # any input dir argument) 1253 # 2.2) --exclude './*.jpg' (Special case for excluding files/dirs 1254 # placed directly underneath the input dir) 1255 for p in args.exclude: 1256 # Only relative paths patterns allowed 1257 if p.startswith(os.sep): 1258 logger.error("Cannot use absolute paths with --exclude") 1259 sys.exit(1) 1260 if os.path.dirname(p): 1261 # We don't support of path patterns with '..' 1262 p_parts = p.split(os.sep) 1263 if '..' in p_parts: 1264 logger.error( 1265 "Cannot use path patterns that include or '..'") 1266 sys.exit(1) 1267 # Path search pattern 1268 exclude_paths.append(p) 1269 else: 1270 # Name-only search pattern 1271 name_patterns.append(p) 1272 # For name only matching, we can combine all patterns into a single 1273 # regexp, for better performance. 1274 exclude_names = re.compile('|'.join( 1275 [fnmatch.translate(p) for p in name_patterns] 1276 )) if len(name_patterns) > 0 else None 1277 # Show the user the patterns to be used, just in case they weren't 1278 # specified inside quotes and got changed by the shell expansion. 1279 logger.info("Exclude patterns: {}".format(args.exclude)) 1280 1281 # If this is used by a human, and there's at least one directory to be 1282 # uploaded, the expected bytes calculation can take a moment. 1283 if args.progress and any([os.path.isdir(f) for f in args.paths]): 1284 logger.info("Calculating upload size, this could take some time...") 1285 try: 1286 writer = ArvPutUploadJob(paths = args.paths, 1287 resume = args.resume, 1288 use_cache = args.use_cache, 1289 batch_mode= args.batch, 1290 filename = args.filename, 1291 reporter = reporter, 1292 api_client = api_client, 1293 num_retries = args.retries, 1294 replication_desired = args.replication, 1295 put_threads = args.threads, 1296 name = collection_name, 1297 owner_uuid = project_uuid, 1298 ensure_unique_name = True, 1299 update_collection = args.update_collection, 1300 storage_classes=args.storage_classes, 1301 logger=logger, 1302 dry_run=args.dry_run, 1303 follow_links=args.follow_links, 1304 exclude_paths=exclude_paths, 1305 exclude_names=exclude_names, 1306 trash_at=trash_at) 1307 except ResumeCacheConflict: 1308 logger.error("\n".join([ 1309 "arv-put: Another process is already uploading this data.", 1310 " Use --no-cache if this is really what you want."])) 1311 sys.exit(1) 1312 except ResumeCacheInvalidError: 1313 logger.error("\n".join([ 1314 "arv-put: Resume cache contains invalid signature: it may have expired", 1315 " or been created with another Arvados user's credentials.", 1316 " Switch user or use one of the following options to restart upload:", 1317 " --no-resume to start a new resume cache.", 1318 " --no-cache to disable resume cache.", 1319 " --batch to ignore the resume cache if invalid."])) 1320 sys.exit(1) 1321 except (CollectionUpdateError, PathDoesNotExistError) as error: 1322 logger.error("\n".join([ 1323 "arv-put: %s" % str(error)])) 1324 sys.exit(1) 1325 except ArvPutUploadIsPending: 1326 # Dry run check successful, return proper exit code. 1327 sys.exit(2) 1328 except ArvPutUploadNotPending: 1329 # No files pending for upload 1330 sys.exit(0) 1331 1332 if not args.dry_run and not args.update_collection and args.resume and writer.bytes_written > 0: 1333 logger.warning("\n".join([ 1334 "arv-put: Resuming previous upload from last checkpoint.", 1335 " Use the --no-resume option to start over."])) 1336 1337 if not args.dry_run: 1338 writer.report_progress() 1339 output = None 1340 try: 1341 writer.start(save_collection=not(args.stream or args.raw)) 1342 except (arvados.errors.ApiError, arvados.errors.KeepWriteError) as error: 1343 logger.error("\n".join([ 1344 "arv-put: %s" % str(error)])) 1345 sys.exit(1) 1346 1347 if args.progress: # Print newline to split stderr from stdout for humans. 1348 logger.info("\n") 1349 1350 if args.stream: 1351 if args.normalize: 1352 output = writer.manifest_text(normalize=True) 1353 else: 1354 output = writer.manifest_text() 1355 elif args.raw: 1356 output = ','.join(writer.data_locators()) 1357 elif writer.manifest_locator() is not None: 1358 try: 1359 expiration_notice = "" 1360 if writer.collection_trash_at() is not None: 1361 # Get the local timezone-naive version, and log it with timezone information. 1362 if time.daylight: 1363 local_trash_at = writer.collection_trash_at().replace(tzinfo=None) - datetime.timedelta(seconds=time.altzone) 1364 else: 1365 local_trash_at = writer.collection_trash_at().replace(tzinfo=None) - datetime.timedelta(seconds=time.timezone) 1366 expiration_notice = ". It will expire on {} {}.".format( 1367 local_trash_at.strftime("%Y-%m-%d %H:%M:%S"), time.strftime("%z")) 1368 if args.update_collection: 1369 logger.info(u"Collection updated: '{}'{}".format( 1370 writer.collection_name(), expiration_notice)) 1371 else: 1372 logger.info(u"Collection saved as '{}'{}".format( 1373 writer.collection_name(), expiration_notice)) 1374 if args.portable_data_hash: 1375 output = writer.portable_data_hash() 1376 else: 1377 output = writer.manifest_locator() 1378 except apiclient_errors.Error as error: 1379 logger.error( 1380 "arv-put: Error creating Collection on project: {}.".format( 1381 error)) 1382 status = 1 1383 else: 1384 status = 1 1385 1386 # Print the locator (uuid) of the new collection. 1387 if output is None: 1388 status = status or 1 1389 elif not args.silent: 1390 stdout.write(output) 1391 if not output.endswith('\n'): 1392 stdout.write('\n') 1393 1394 if install_sig_handlers: 1395 arv_cmd.restore_signal_handlers() 1396 1397 if status != 0: 1398 sys.exit(status) 1399 1400 # Success! 1401 return output