arvados.collection
Tools to work with Arvados collections
This module provides high-level interfaces to create, read, and update
Arvados collections. Most users will want to instantiate Collection
objects, and use methods like Collection.open and Collection.mkdirs to
read and write data in the collection. Refer to the Arvados Python SDK
cookbook for an introduction to using the Collection class.
1# Copyright (C) The Arvados Authors. All rights reserved. 2# 3# SPDX-License-Identifier: Apache-2.0 4"""Tools to work with Arvados collections 5 6This module provides high-level interfaces to create, read, and update 7Arvados collections. Most users will want to instantiate `Collection` 8objects, and use methods like `Collection.open` and `Collection.mkdirs` to 9read and write data in the collection. Refer to the Arvados Python SDK 10cookbook for [an introduction to using the Collection class][cookbook]. 11 12[cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections 13""" 14 15import ciso8601 16import datetime 17import errno 18import functools 19import hashlib 20import io 21import logging 22import os 23import re 24import sys 25import threading 26import time 27 28from collections import deque 29from stat import * 30 31from ._internal import streams 32from .api import ThreadSafeAPIClient 33from .arvfile import split, _FileLikeObjectBase, ArvadosFile, ArvadosFileWriter, ArvadosFileReader, WrappableFile, _BlockManager, synchronized, must_be_writable, NoopLock, ADD, DEL, MOD, TOK, WRITE 34from .keep import KeepLocator, KeepClient 35import arvados.config as config 36import arvados.errors as errors 37import arvados.util 38import arvados.events as events 39from arvados.retry import retry_method 40 41from typing import ( 42 Any, 43 Callable, 44 Dict, 45 IO, 46 Iterator, 47 List, 48 Mapping, 49 Optional, 50 Tuple, 51 Union, 52) 53 54if sys.version_info < (3, 8): 55 from typing_extensions import Literal 56else: 57 from typing import Literal 58 59_logger = logging.getLogger('arvados.collection') 60 61 62FILE = "file" 63"""`create_type` value for `Collection.find_or_create`""" 64COLLECTION = "collection" 65"""`create_type` value for `Collection.find_or_create`""" 66 67ChangeList = List[Union[ 68 Tuple[Literal[ADD, DEL], str, 'Collection'], 69 Tuple[Literal[MOD, TOK], str, 'Collection', 'Collection'], 70]] 71ChangeType = Literal[ADD, DEL, MOD, TOK] 72CollectionItem = Union[ArvadosFile, 'Collection', 'Subcollection'] 73ChangeCallback = Callable[[ChangeType, 'Collection', str, CollectionItem], object] 74CreateType = Literal[COLLECTION, FILE] 75Properties = Dict[str, Any] 76StorageClasses = List[str] 77 78class CollectionBase(object): 79 """Abstract base class for Collection classes 80 81 .. ATTENTION:: Internal 82 This class is meant to be used by other parts of the SDK. User code 83 should instantiate or subclass `Collection` or one of its subclasses 84 directly. 85 """ 86 87 def __enter__(self): 88 """Enter a context block with this collection instance""" 89 return self 90 91 def __exit__(self, exc_type, exc_value, traceback): 92 """Exit a context block with this collection instance""" 93 pass 94 95 def _my_keep(self): 96 if self._keep_client is None: 97 self._keep_client = KeepClient(api_client=self._api_client, 98 num_retries=self.num_retries) 99 return self._keep_client 100 101 def stripped_manifest(self) -> str: 102 """Create a copy of the collection manifest with only size hints 103 104 This method returns a string with the current collection's manifest 105 text with all non-portable locator hints like permission hints and 106 remote cluster hints removed. The only hints in the returned manifest 107 will be size hints. 108 """ 109 raw = self.manifest_text() 110 clean = [] 111 for line in raw.split("\n"): 112 fields = line.split() 113 if fields: 114 clean_fields = fields[:1] + [ 115 (re.sub(r'\+[^\d][^\+]*', '', x) 116 if re.match(arvados.util.keep_locator_pattern, x) 117 else x) 118 for x in fields[1:]] 119 clean += [' '.join(clean_fields), "\n"] 120 return ''.join(clean) 121 122 123class _WriterFile(_FileLikeObjectBase): 124 def __init__(self, coll_writer, name): 125 super(_WriterFile, self).__init__(name, 'wb') 126 self.dest = coll_writer 127 128 def close(self): 129 super(_WriterFile, self).close() 130 self.dest.finish_current_file() 131 132 @_FileLikeObjectBase._before_close 133 def write(self, data): 134 self.dest.write(data) 135 136 @_FileLikeObjectBase._before_close 137 def writelines(self, seq): 138 for data in seq: 139 self.write(data) 140 141 @_FileLikeObjectBase._before_close 142 def flush(self): 143 self.dest.flush_data() 144 145 146class RichCollectionBase(CollectionBase): 147 """Base class for Collection classes 148 149 .. ATTENTION:: Internal 150 This class is meant to be used by other parts of the SDK. User code 151 should instantiate or subclass `Collection` or one of its subclasses 152 directly. 153 """ 154 155 def __init__(self, parent=None): 156 self.parent = parent 157 self._committed = False 158 self._has_remote_blocks = False 159 self._callback = None 160 self._items = {} 161 162 def _my_api(self): 163 raise NotImplementedError() 164 165 def _my_keep(self): 166 raise NotImplementedError() 167 168 def _my_block_manager(self): 169 raise NotImplementedError() 170 171 def writable(self) -> bool: 172 """Indicate whether this collection object can be modified 173 174 This method returns `False` if this object is a `CollectionReader`, 175 else `True`. 176 """ 177 raise NotImplementedError() 178 179 def root_collection(self) -> 'Collection': 180 """Get this collection's root collection object 181 182 If you open a subcollection with `Collection.find`, calling this method 183 on that subcollection returns the source Collection object. 184 """ 185 raise NotImplementedError() 186 187 def stream_name(self) -> str: 188 """Get the name of the manifest stream represented by this collection 189 190 If you open a subcollection with `Collection.find`, calling this method 191 on that subcollection returns the name of the stream you opened. 192 """ 193 raise NotImplementedError() 194 195 @synchronized 196 def has_remote_blocks(self) -> bool: 197 """Indiciate whether the collection refers to remote data 198 199 Returns `True` if the collection manifest includes any Keep locators 200 with a remote hint (`+R`), else `False`. 201 """ 202 if self._has_remote_blocks: 203 return True 204 for item in self: 205 if self[item].has_remote_blocks(): 206 return True 207 return False 208 209 @synchronized 210 def set_has_remote_blocks(self, val: bool) -> None: 211 """Cache whether this collection refers to remote blocks 212 213 .. ATTENTION:: Internal 214 This method is only meant to be used by other Collection methods. 215 216 Set this collection's cached "has remote blocks" flag to the given 217 value. 218 """ 219 self._has_remote_blocks = val 220 if self.parent: 221 self.parent.set_has_remote_blocks(val) 222 223 @must_be_writable 224 @synchronized 225 def find_or_create( 226 self, 227 path: str, 228 create_type: CreateType, 229 ) -> CollectionItem: 230 """Get the item at the given path, creating it if necessary 231 232 If `path` refers to a stream in this collection, returns a 233 corresponding `Subcollection` object. If `path` refers to a file in 234 this collection, returns a corresponding 235 `arvados.arvfile.ArvadosFile` object. If `path` does not exist in 236 this collection, then this method creates a new object and returns 237 it, creating parent streams as needed. The type of object created is 238 determined by the value of `create_type`. 239 240 Arguments: 241 242 * path: str --- The path to find or create within this collection. 243 244 * create_type: Literal[COLLECTION, FILE] --- The type of object to 245 create at `path` if one does not exist. Passing `COLLECTION` 246 creates a stream and returns the corresponding 247 `Subcollection`. Passing `FILE` creates a new file and returns the 248 corresponding `arvados.arvfile.ArvadosFile`. 249 """ 250 pathcomponents = path.split("/", 1) 251 if pathcomponents[0]: 252 item = self._items.get(pathcomponents[0]) 253 if len(pathcomponents) == 1: 254 if item is None: 255 # create new file 256 if create_type == COLLECTION: 257 item = Subcollection(self, pathcomponents[0]) 258 else: 259 item = ArvadosFile(self, pathcomponents[0]) 260 self._items[pathcomponents[0]] = item 261 self.set_committed(False) 262 self.notify(ADD, self, pathcomponents[0], item) 263 return item 264 else: 265 if item is None: 266 # create new collection 267 item = Subcollection(self, pathcomponents[0]) 268 self._items[pathcomponents[0]] = item 269 self.set_committed(False) 270 self.notify(ADD, self, pathcomponents[0], item) 271 if isinstance(item, RichCollectionBase): 272 return item.find_or_create(pathcomponents[1], create_type) 273 else: 274 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 275 else: 276 return self 277 278 @synchronized 279 def find(self, path: str) -> CollectionItem | None: 280 """Get the item at the given path. 281 282 If `path` refers to a stream in this collection, returns a 283 corresponding `Subcollection` object. 284 285 As a special case, if `path` is `"."`, returns the collection itself. 286 287 If `path` refers to a file in this collection, returns a corresponding 288 `arvados.arvfile.ArvadosFile` object. 289 290 If `path` does not exist in this collection, then this method returns 291 `None`. 292 293 A `path` that begins with the slash (`/`) character is invalid; 294 `NotADirectoryError` is raised in this case. 295 296 An empty `path` is invalid, and `arvados.errors.ArgumentError` is 297 raised. 298 299 Arguments: 300 301 * path: str --- The path to find within this collection. 302 """ 303 if not path: 304 raise errors.ArgumentError("Parameter 'path' is empty.") 305 306 pathcomponents = path.split("/", 1) 307 if pathcomponents[0] == '': 308 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 309 310 item = self._items.get(pathcomponents[0]) 311 if item is None: 312 return None 313 elif len(pathcomponents) == 1: 314 return item 315 else: 316 if isinstance(item, RichCollectionBase): 317 if pathcomponents[1]: 318 return item.find(pathcomponents[1]) 319 else: 320 return item 321 else: 322 raise IOError( 323 errno.ENOTDIR, "Not a directory", pathcomponents[0] 324 ) 325 326 @synchronized 327 def mkdirs(self, path: str) -> 'Subcollection': 328 """Create and return a subcollection at `path` 329 330 If `path` exists within this collection, raises `FileExistsError`. 331 Otherwise, creates a stream at that path and returns the 332 corresponding `Subcollection`. 333 """ 334 if self.find(path) != None: 335 raise IOError(errno.EEXIST, "Directory or file exists", path) 336 337 return self.find_or_create(path, COLLECTION) 338 339 def open( 340 self, 341 path: str, 342 mode: str="r", 343 encoding: Optional[str]=None 344 ) -> IO: 345 """Open a file-like object within the collection 346 347 This method returns a file-like object that can read and/or write the 348 file located at `path` within the collection. If you attempt to write 349 a `path` that does not exist, the file is created with `find_or_create`. 350 If the file cannot be opened for any other reason, this method raises 351 `OSError` with an appropriate errno. 352 353 Arguments: 354 355 * path: str --- The path of the file to open within this collection 356 357 * mode: str --- The mode to open this file. Supports all the same 358 values as `builtins.open`. 359 360 * encoding: str | None --- The text encoding of the file. Only used 361 when the file is opened in text mode. The default is 362 platform-dependent. 363 364 """ 365 if not re.search(r'^[rwa][bt]?\+?$', mode): 366 raise errors.ArgumentError("Invalid mode {!r}".format(mode)) 367 368 if mode[0] == 'r' and '+' not in mode: 369 fclass = ArvadosFileReader 370 arvfile = self.find(path) 371 elif not self.writable(): 372 raise IOError(errno.EROFS, "Collection is read only") 373 else: 374 fclass = ArvadosFileWriter 375 arvfile = self.find_or_create(path, FILE) 376 377 if arvfile is None: 378 raise IOError(errno.ENOENT, "File not found", path) 379 if not isinstance(arvfile, ArvadosFile): 380 raise IOError(errno.EISDIR, "Is a directory", path) 381 382 if mode[0] == 'w': 383 arvfile.truncate(0) 384 385 binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:]) 386 f = fclass(arvfile, mode=binmode, num_retries=self.num_retries) 387 if 'b' not in mode: 388 bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader 389 f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding) 390 return f 391 392 def modified(self) -> bool: 393 """Indicate whether this collection has an API server record 394 395 Returns `False` if this collection corresponds to a record loaded from 396 the API server, `True` otherwise. 397 """ 398 return not self.committed() 399 400 @synchronized 401 def committed(self): 402 """Indicate whether this collection has an API server record 403 404 Returns `True` if this collection corresponds to a record loaded from 405 the API server, `False` otherwise. 406 """ 407 return self._committed 408 409 @synchronized 410 def set_committed(self, value: bool=True): 411 """Cache whether this collection has an API server record 412 413 .. ATTENTION:: Internal 414 This method is only meant to be used by other Collection methods. 415 416 Set this collection's cached "committed" flag to the given 417 value and propagates it as needed. 418 """ 419 if value == self._committed: 420 return 421 if value: 422 for k,v in self._items.items(): 423 v.set_committed(True) 424 self._committed = True 425 else: 426 self._committed = False 427 if self.parent is not None: 428 self.parent.set_committed(False) 429 430 @synchronized 431 def __iter__(self) -> Iterator[str]: 432 """Iterate names of streams and files in this collection 433 434 This method does not recurse. It only iterates the contents of this 435 collection's corresponding stream. 436 """ 437 return iter(self._items) 438 439 @synchronized 440 def __getitem__(self, k: str) -> CollectionItem: 441 """Get a `arvados.arvfile.ArvadosFile` or `Subcollection` in this collection 442 443 This method does not recurse. If you want to search a path, use 444 `RichCollectionBase.find` instead. 445 """ 446 return self._items[k] 447 448 @synchronized 449 def __contains__(self, k: str) -> bool: 450 """Indicate whether this collection has an item with this name 451 452 This method does not recurse. It you want to check a path, use 453 `RichCollectionBase.exists` instead. 454 """ 455 return k in self._items 456 457 @synchronized 458 def __len__(self): 459 """Get the number of items directly contained in this collection 460 461 This method does not recurse. It only counts the streams and files 462 in this collection's corresponding stream. 463 """ 464 return len(self._items) 465 466 @must_be_writable 467 @synchronized 468 def __delitem__(self, p: str) -> None: 469 """Delete an item from this collection's stream 470 471 This method does not recurse. If you want to remove an item by a 472 path, use `RichCollectionBase.remove` instead. 473 """ 474 del self._items[p] 475 self.set_committed(False) 476 self.notify(DEL, self, p, None) 477 478 @synchronized 479 def keys(self) -> Iterator[str]: 480 """Iterate names of streams and files in this collection 481 482 This method does not recurse. It only iterates the contents of this 483 collection's corresponding stream. 484 """ 485 return self._items.keys() 486 487 @synchronized 488 def values(self) -> List[CollectionItem]: 489 """Get a list of objects in this collection's stream 490 491 The return value includes a `Subcollection` for every stream, and an 492 `arvados.arvfile.ArvadosFile` for every file, directly within this 493 collection's stream. This method does not recurse. 494 """ 495 return list(self._items.values()) 496 497 @synchronized 498 def items(self) -> List[Tuple[str, CollectionItem]]: 499 """Get a list of `(name, object)` tuples from this collection's stream 500 501 The return value includes a `Subcollection` for every stream, and an 502 `arvados.arvfile.ArvadosFile` for every file, directly within this 503 collection's stream. This method does not recurse. 504 """ 505 return list(self._items.items()) 506 507 def exists(self, path: str) -> bool: 508 """Indicate whether this collection includes an item at `path` 509 510 This method returns `True` if `path` refers to a stream or file within 511 this collection, else `False`. 512 513 Arguments: 514 515 * path: str --- The path to check for existence within this collection 516 """ 517 return self.find(path) is not None 518 519 @must_be_writable 520 @synchronized 521 def remove(self, path: str, recursive: bool=False) -> None: 522 """Remove the file or stream at `path` 523 524 Arguments: 525 526 * path: str --- The path of the item to remove from the collection 527 528 * recursive: bool --- Controls the method's behavior if `path` refers 529 to a nonempty stream. If `False` (the default), this method raises 530 `OSError` with errno `ENOTEMPTY`. If `True`, this method removes all 531 items under the stream. 532 """ 533 if not path: 534 raise errors.ArgumentError("Parameter 'path' is empty.") 535 536 pathcomponents = path.split("/", 1) 537 item = self._items.get(pathcomponents[0]) 538 if item is None: 539 raise IOError(errno.ENOENT, "File not found", path) 540 if len(pathcomponents) == 1: 541 if isinstance(self._items[pathcomponents[0]], RichCollectionBase) and len(self._items[pathcomponents[0]]) > 0 and not recursive: 542 raise IOError(errno.ENOTEMPTY, "Directory not empty", path) 543 deleteditem = self._items[pathcomponents[0]] 544 del self._items[pathcomponents[0]] 545 self.set_committed(False) 546 self.notify(DEL, self, pathcomponents[0], deleteditem) 547 else: 548 item.remove(pathcomponents[1], recursive=recursive) 549 550 def _clonefrom(self, source): 551 for k,v in source.items(): 552 self._items[k] = v.clone(self, k) 553 554 def clone(self): 555 raise NotImplementedError() 556 557 @must_be_writable 558 @synchronized 559 def add( 560 self, 561 source_obj: CollectionItem, 562 target_name: str, 563 overwrite: bool=False, 564 reparent: bool=False, 565 ) -> None: 566 """Copy or move a file or subcollection object to this collection 567 568 Arguments: 569 570 * source_obj: arvados.arvfile.ArvadosFile | Subcollection --- The file or subcollection 571 to add to this collection 572 573 * target_name: str --- The path inside this collection where 574 `source_obj` should be added. 575 576 * overwrite: bool --- Controls the behavior of this method when the 577 collection already contains an object at `target_name`. If `False` 578 (the default), this method will raise `FileExistsError`. If `True`, 579 the object at `target_name` will be replaced with `source_obj`. 580 581 * reparent: bool --- Controls whether this method copies or moves 582 `source_obj`. If `False` (the default), `source_obj` is copied into 583 this collection. If `True`, `source_obj` is moved into this 584 collection. 585 """ 586 if target_name in self and not overwrite: 587 raise IOError(errno.EEXIST, "File already exists", target_name) 588 589 modified_from = None 590 if target_name in self: 591 modified_from = self[target_name] 592 593 # Actually make the move or copy. 594 if reparent: 595 source_obj._reparent(self, target_name) 596 item = source_obj 597 else: 598 item = source_obj.clone(self, target_name) 599 600 self._items[target_name] = item 601 self.set_committed(False) 602 if not self._has_remote_blocks and source_obj.has_remote_blocks(): 603 self.set_has_remote_blocks(True) 604 605 if modified_from: 606 self.notify(MOD, self, target_name, (modified_from, item)) 607 else: 608 self.notify(ADD, self, target_name, item) 609 610 def _get_src_target(self, source, target_path, source_collection, create_dest): 611 if source_collection is None: 612 source_collection = self 613 614 # Find the object 615 if isinstance(source, str): 616 source_obj = source_collection.find(source) 617 if source_obj is None: 618 raise IOError(errno.ENOENT, "File not found", source) 619 sourcecomponents = source.split("/") 620 else: 621 source_obj = source 622 sourcecomponents = None 623 624 # Find parent collection the target path 625 targetcomponents = target_path.split("/") 626 627 # Determine the name to use. 628 target_name = targetcomponents[-1] if targetcomponents[-1] else sourcecomponents[-1] 629 630 if not target_name: 631 raise errors.ArgumentError("Target path is empty and source is an object. Cannot determine destination filename to use.") 632 633 if create_dest: 634 target_dir = self.find_or_create("/".join(targetcomponents[0:-1]), COLLECTION) 635 else: 636 if len(targetcomponents) > 1: 637 target_dir = self.find("/".join(targetcomponents[0:-1])) 638 else: 639 target_dir = self 640 641 if target_dir is None: 642 raise IOError(errno.ENOENT, "Target directory not found", target_name) 643 644 if target_name in target_dir and isinstance(target_dir[target_name], RichCollectionBase) and sourcecomponents: 645 target_dir = target_dir[target_name] 646 target_name = sourcecomponents[-1] 647 648 return (source_obj, target_dir, target_name) 649 650 @must_be_writable 651 @synchronized 652 def copy( 653 self, 654 source: Union[str, CollectionItem], 655 target_path: str, 656 source_collection: Optional['RichCollectionBase']=None, 657 overwrite: bool=False, 658 ) -> None: 659 """Copy a file or subcollection object to this collection 660 661 Arguments: 662 663 * source: str | arvados.arvfile.ArvadosFile | 664 arvados.collection.Subcollection --- The file or subcollection to 665 add to this collection. If `source` is a str, the object will be 666 found by looking up this path from `source_collection` (see 667 below). 668 669 * target_path: str --- The path inside this collection where the 670 source object should be added. 671 672 * source_collection: arvados.collection.Collection | None --- The 673 collection to find the source object from when `source` is a 674 path. Defaults to the current collection (`self`). 675 676 * overwrite: bool --- Controls the behavior of this method when the 677 collection already contains an object at `target_path`. If `False` 678 (the default), this method will raise `FileExistsError`. If `True`, 679 the object at `target_path` will be replaced with `source_obj`. 680 """ 681 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, True) 682 target_dir.add(source_obj, target_name, overwrite, False) 683 684 @must_be_writable 685 @synchronized 686 def rename( 687 self, 688 source: Union[str, CollectionItem], 689 target_path: str, 690 source_collection: Optional['RichCollectionBase']=None, 691 overwrite: bool=False, 692 ) -> None: 693 """Move a file or subcollection object to this collection 694 695 Arguments: 696 697 * source: str | arvados.arvfile.ArvadosFile | 698 arvados.collection.Subcollection --- The file or subcollection to 699 add to this collection. If `source` is a str, the object will be 700 found by looking up this path from `source_collection` (see 701 below). 702 703 * target_path: str --- The path inside this collection where the 704 source object should be added. 705 706 * source_collection: arvados.collection.Collection | None --- The 707 collection to find the source object from when `source` is a 708 path. Defaults to the current collection (`self`). 709 710 * overwrite: bool --- Controls the behavior of this method when the 711 collection already contains an object at `target_path`. If `False` 712 (the default), this method will raise `FileExistsError`. If `True`, 713 the object at `target_path` will be replaced with `source_obj`. 714 """ 715 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, False) 716 if not source_obj.writable(): 717 raise IOError(errno.EROFS, "Source collection is read only", source) 718 target_dir.add(source_obj, target_name, overwrite, True) 719 720 def portable_manifest_text(self, stream_name: str=".") -> str: 721 """Get the portable manifest text for this collection 722 723 The portable manifest text is normalized, and does not include access 724 tokens. This method does not flush outstanding blocks to Keep. 725 726 Arguments: 727 728 * stream_name: str --- The name to use for this collection's stream in 729 the generated manifest. Default `'.'`. 730 """ 731 return self._get_manifest_text(stream_name, True, True) 732 733 @synchronized 734 def manifest_text( 735 self, 736 stream_name: str=".", 737 strip: bool=False, 738 normalize: bool=False, 739 only_committed: bool=False, 740 ) -> str: 741 """Get the manifest text for this collection 742 743 Arguments: 744 745 * stream_name: str --- The name to use for this collection's stream in 746 the generated manifest. Default `'.'`. 747 748 * strip: bool --- Controls whether or not the returned manifest text 749 includes access tokens. If `False` (the default), the manifest text 750 will include access tokens. If `True`, the manifest text will not 751 include access tokens. 752 753 * normalize: bool --- Controls whether or not the returned manifest 754 text is normalized. Default `False`. 755 756 * only_committed: bool --- Controls whether or not this method uploads 757 pending data to Keep before building and returning the manifest text. 758 If `False` (the default), this method will finish uploading all data 759 to Keep, then return the final manifest. If `True`, this method will 760 build and return a manifest that only refers to the data that has 761 finished uploading at the time this method was called. 762 """ 763 if not only_committed: 764 self._my_block_manager().commit_all() 765 return self._get_manifest_text(stream_name, strip, normalize, 766 only_committed=only_committed) 767 768 @synchronized 769 def _get_manifest_text(self, stream_name, strip, normalize, only_committed=False): 770 """Get the manifest text for this collection, sub collections and files. 771 772 :stream_name: 773 Name to use for this stream (directory) 774 775 :strip: 776 If True, remove signing tokens from block locators if present. 777 If False (default), block locators are left unchanged. 778 779 :normalize: 780 If True, always export the manifest text in normalized form 781 even if the Collection is not modified. If False (default) and the collection 782 is not modified, return the original manifest text even if it is not 783 in normalized form. 784 785 :only_committed: 786 If True, only include blocks that were already committed to Keep. 787 788 """ 789 790 if not self.committed() or self._manifest_text is None or normalize: 791 stream = {} 792 buf = [] 793 sorted_keys = sorted(self.keys()) 794 for filename in [s for s in sorted_keys if isinstance(self[s], ArvadosFile)]: 795 # Create a stream per file `k` 796 arvfile = self[filename] 797 filestream = [] 798 for segment in arvfile.segments(): 799 loc = segment.locator 800 if arvfile.parent._my_block_manager().is_bufferblock(loc): 801 if only_committed: 802 continue 803 loc = arvfile.parent._my_block_manager().get_bufferblock(loc).locator() 804 if strip: 805 loc = KeepLocator(loc).stripped() 806 filestream.append(streams.LocatorAndRange( 807 loc, 808 KeepLocator(loc).size, 809 segment.segment_offset, 810 segment.range_size, 811 )) 812 stream[filename] = filestream 813 if stream: 814 buf.append(" ".join(streams.normalize_stream(stream_name, stream)) + "\n") 815 for dirname in [s for s in sorted_keys if isinstance(self[s], RichCollectionBase)]: 816 buf.append(self[dirname].manifest_text( 817 stream_name=os.path.join(stream_name, dirname), 818 strip=strip, normalize=True, only_committed=only_committed)) 819 return "".join(buf) 820 else: 821 if strip: 822 return self.stripped_manifest() 823 else: 824 return self._manifest_text 825 826 @synchronized 827 def _copy_remote_blocks(self, remote_blocks={}): 828 """Scan through the entire collection and ask Keep to copy remote blocks. 829 830 When accessing a remote collection, blocks will have a remote signature 831 (+R instead of +A). Collect these signatures and request Keep to copy the 832 blocks to the local cluster, returning local (+A) signatures. 833 834 :remote_blocks: 835 Shared cache of remote to local block mappings. This is used to avoid 836 doing extra work when blocks are shared by more than one file in 837 different subdirectories. 838 839 """ 840 for item in self: 841 remote_blocks = self[item]._copy_remote_blocks(remote_blocks) 842 return remote_blocks 843 844 @synchronized 845 def diff( 846 self, 847 end_collection: 'RichCollectionBase', 848 prefix: str=".", 849 holding_collection: Optional['Collection']=None, 850 ) -> ChangeList: 851 """Build a list of differences between this collection and another 852 853 Arguments: 854 855 * end_collection: arvados.collection.RichCollectionBase --- A 856 collection object with the desired end state. The returned diff 857 list will describe how to go from the current collection object 858 `self` to `end_collection`. 859 860 * prefix: str --- The name to use for this collection's stream in 861 the diff list. Default `'.'`. 862 863 * holding_collection: arvados.collection.Collection | None --- A 864 collection object used to hold objects for the returned diff 865 list. By default, a new empty collection is created. 866 """ 867 changes = [] 868 if holding_collection is None: 869 holding_collection = Collection(api_client=self._my_api(), keep_client=self._my_keep()) 870 for k in self: 871 if k not in end_collection: 872 changes.append((DEL, os.path.join(prefix, k), self[k].clone(holding_collection, ""))) 873 for k in end_collection: 874 if k in self: 875 if isinstance(end_collection[k], Subcollection) and isinstance(self[k], Subcollection): 876 changes.extend(self[k].diff(end_collection[k], os.path.join(prefix, k), holding_collection)) 877 elif end_collection[k] != self[k]: 878 changes.append((MOD, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 879 else: 880 changes.append((TOK, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 881 else: 882 changes.append((ADD, os.path.join(prefix, k), end_collection[k].clone(holding_collection, ""))) 883 return changes 884 885 @must_be_writable 886 @synchronized 887 def apply(self, changes: ChangeList) -> None: 888 """Apply a list of changes from to this collection 889 890 This method takes a list of changes generated by 891 `RichCollectionBase.diff` and applies it to this 892 collection. Afterward, the state of this collection object will 893 match the state of `end_collection` passed to `diff`. If a change 894 conflicts with a local change, it will be saved to an alternate path 895 indicating the conflict. 896 897 Arguments: 898 899 * changes: arvados.collection.ChangeList --- The list of differences 900 generated by `RichCollectionBase.diff`. 901 """ 902 if changes: 903 self.set_committed(False) 904 for change in changes: 905 event_type = change[0] 906 path = change[1] 907 initial = change[2] 908 local = self.find(path) 909 conflictpath = "%s~%s~conflict~" % (path, time.strftime("%Y%m%d-%H%M%S", 910 time.gmtime())) 911 if event_type == ADD: 912 if local is None: 913 # No local file at path, safe to copy over new file 914 self.copy(initial, path) 915 elif local is not None and local != initial: 916 # There is already local file and it is different: 917 # save change to conflict file. 918 self.copy(initial, conflictpath) 919 elif event_type == MOD or event_type == TOK: 920 final = change[3] 921 if local == initial: 922 # Local matches the "initial" item so it has not 923 # changed locally and is safe to update. 924 if isinstance(local, ArvadosFile) and isinstance(final, ArvadosFile): 925 # Replace contents of local file with new contents 926 local.replace_contents(final) 927 else: 928 # Overwrite path with new item; this can happen if 929 # path was a file and is now a collection or vice versa 930 self.copy(final, path, overwrite=True) 931 elif event_type == MOD: 932 # Local doesn't match the "start" value or local 933 # is missing (presumably deleted) so save change 934 # to conflict file. Don't do this for TOK events 935 # which means the file didn't change but only had 936 # tokens updated. 937 self.copy(final, conflictpath) 938 elif event_type == DEL: 939 if local == initial: 940 # Local item matches "initial" value, so it is safe to remove. 941 self.remove(path, recursive=True) 942 # else, the file is modified or already removed, in either 943 # case we don't want to try to remove it. 944 945 def portable_data_hash(self) -> str: 946 """Get the portable data hash for this collection's manifest""" 947 if self._manifest_locator and self.committed(): 948 # If the collection is already saved on the API server, and it's committed 949 # then return API server's PDH response. 950 return self._portable_data_hash 951 else: 952 stripped = self.portable_manifest_text().encode() 953 return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped)) 954 955 @synchronized 956 def subscribe(self, callback: ChangeCallback) -> None: 957 """Set a notify callback for changes to this collection 958 959 Arguments: 960 961 * callback: arvados.collection.ChangeCallback --- The callable to 962 call each time the collection is changed. 963 """ 964 if self._callback is None: 965 self._callback = callback 966 else: 967 raise errors.ArgumentError("A callback is already set on this collection.") 968 969 @synchronized 970 def unsubscribe(self) -> None: 971 """Remove any notify callback set for changes to this collection""" 972 if self._callback is not None: 973 self._callback = None 974 975 @synchronized 976 def notify( 977 self, 978 event: ChangeType, 979 collection: 'RichCollectionBase', 980 name: str, 981 item: CollectionItem, 982 ) -> None: 983 """Notify any subscribed callback about a change to this collection 984 985 .. ATTENTION:: Internal 986 This method is only meant to be used by other Collection methods. 987 988 If a callback has been registered with `RichCollectionBase.subscribe`, 989 it will be called with information about a change to this collection. 990 Then this notification will be propagated to this collection's root. 991 992 Arguments: 993 994 * event: Literal[ADD, DEL, MOD, TOK] --- The type of modification to 995 the collection. 996 997 * collection: arvados.collection.RichCollectionBase --- The 998 collection that was modified. 999 1000 * name: str --- The name of the file or stream within `collection` that 1001 was modified. 1002 1003 * item: arvados.arvfile.ArvadosFile | 1004 arvados.collection.Subcollection --- For ADD events, the new 1005 contents at `name` within `collection`; for DEL events, the 1006 item that was removed. For MOD and TOK events, a 2-tuple of 1007 the previous item and the new item (may be the same object 1008 or different, depending on whether the action involved it 1009 being modified in place or replaced). 1010 1011 """ 1012 if self._callback: 1013 self._callback(event, collection, name, item) 1014 self.root_collection().notify(event, collection, name, item) 1015 1016 @synchronized 1017 def __eq__(self, other: Any) -> bool: 1018 """Indicate whether this collection object is equal to another""" 1019 if other is self: 1020 return True 1021 if not isinstance(other, RichCollectionBase): 1022 return False 1023 if len(self._items) != len(other): 1024 return False 1025 for k in self._items: 1026 if k not in other: 1027 return False 1028 if self._items[k] != other[k]: 1029 return False 1030 return True 1031 1032 def __ne__(self, other: Any) -> bool: 1033 """Indicate whether this collection object is not equal to another""" 1034 return not self.__eq__(other) 1035 1036 @synchronized 1037 def flush(self) -> None: 1038 """Upload any pending data to Keep""" 1039 for e in self.values(): 1040 e.flush() 1041 1042 1043class Collection(RichCollectionBase): 1044 """Read and manipulate an Arvados collection 1045 1046 This class provides a high-level interface to create, read, and update 1047 Arvados collections and their contents. Refer to the Arvados Python SDK 1048 cookbook for [an introduction to using the Collection class][cookbook]. 1049 1050 [cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections 1051 """ 1052 1053 def __init__(self, manifest_locator_or_text: Optional[str]=None, 1054 api_client: Optional['arvados.api_resources.ArvadosAPIClient']=None, 1055 keep_client: Optional['arvados.keep.KeepClient']=None, 1056 num_retries: int=10, 1057 parent: Optional['Collection']=None, 1058 apiconfig: Optional[Mapping[str, str]]=None, 1059 block_manager: Optional['arvados.arvfile._BlockManager']=None, 1060 replication_desired: Optional[int]=None, 1061 storage_classes_desired: Optional[List[str]]=None, 1062 put_threads: Optional[int]=None): 1063 """Initialize a Collection object 1064 1065 Arguments: 1066 1067 * manifest_locator_or_text: str | None --- This string can contain a 1068 collection manifest text, portable data hash, or UUID. When given a 1069 portable data hash or UUID, this instance will load a collection 1070 record from the API server. Otherwise, this instance will represent a 1071 new collection without an API server record. The default value `None` 1072 instantiates a new collection with an empty manifest. 1073 1074 * api_client: arvados.api_resources.ArvadosAPIClient | None --- The 1075 Arvados API client object this instance uses to make requests. If 1076 none is given, this instance creates its own client using the 1077 settings from `apiconfig` (see below). If your client instantiates 1078 many Collection objects, you can help limit memory utilization by 1079 calling `arvados.api.api` to construct an 1080 `arvados.api.ThreadSafeAPIClient`, and use that as the `api_client` 1081 for every Collection. 1082 1083 * keep_client: arvados.keep.KeepClient | None --- The Keep client 1084 object this instance uses to make requests. If none is given, this 1085 instance creates its own client using its `api_client`. 1086 1087 * num_retries: int --- The number of times that client requests are 1088 retried. Default 10. 1089 1090 * parent: arvados.collection.Collection | None --- The parent Collection 1091 object of this instance, if any. This argument is primarily used by 1092 other Collection methods; user client code shouldn't need to use it. 1093 1094 * apiconfig: Mapping[str, str] | None --- A mapping with entries for 1095 `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally 1096 `ARVADOS_API_HOST_INSECURE`. When no `api_client` is provided, the 1097 Collection object constructs one from these settings. If no 1098 mapping is provided, calls `arvados.config.settings` to get these 1099 parameters from user configuration. 1100 1101 * block_manager: arvados.arvfile._BlockManager | None --- The 1102 _BlockManager object used by this instance to coordinate reading 1103 and writing Keep data blocks. If none is given, this instance 1104 constructs its own. This argument is primarily used by other 1105 Collection methods; user client code shouldn't need to use it. 1106 1107 * replication_desired: int | None --- This controls both the value of 1108 the `replication_desired` field on API collection records saved by 1109 this class, as well as the number of Keep services that the object 1110 writes new data blocks to. If none is given, uses the default value 1111 configured for the cluster. 1112 1113 * storage_classes_desired: list[str] | None --- This controls both 1114 the value of the `storage_classes_desired` field on API collection 1115 records saved by this class, as well as selecting which specific 1116 Keep services the object writes new data blocks to. If none is 1117 given, defaults to an empty list. 1118 1119 * put_threads: int | None --- The number of threads to run 1120 simultaneously to upload data blocks to Keep. This value is used when 1121 building a new `block_manager`. It is unused when a `block_manager` 1122 is provided. 1123 """ 1124 1125 if storage_classes_desired and type(storage_classes_desired) is not list: 1126 raise errors.ArgumentError("storage_classes_desired must be list type.") 1127 1128 super(Collection, self).__init__(parent) 1129 self._api_client = api_client 1130 self._keep_client = keep_client 1131 1132 # Use the keep client from ThreadSafeAPIClient 1133 if self._keep_client is None and isinstance(self._api_client, ThreadSafeAPIClient): 1134 self._keep_client = self._api_client.keep 1135 1136 self._block_manager = block_manager 1137 self.replication_desired = replication_desired 1138 self._storage_classes_desired = storage_classes_desired 1139 self.put_threads = put_threads 1140 1141 if apiconfig: 1142 self._config = apiconfig 1143 else: 1144 self._config = config.settings() 1145 1146 self.num_retries = num_retries 1147 self._manifest_locator = None 1148 self._manifest_text = None 1149 self._portable_data_hash = None 1150 self._api_response = None 1151 self._token_refresh_timestamp = 0 1152 1153 self.lock = threading.RLock() 1154 self.events = None 1155 1156 if manifest_locator_or_text: 1157 if re.match(arvados.util.keep_locator_pattern, manifest_locator_or_text): 1158 self._manifest_locator = manifest_locator_or_text 1159 elif re.match(arvados.util.collection_uuid_pattern, manifest_locator_or_text): 1160 self._manifest_locator = manifest_locator_or_text 1161 if not self._has_local_collection_uuid(): 1162 self._has_remote_blocks = True 1163 elif re.match(arvados.util.manifest_pattern, manifest_locator_or_text): 1164 self._manifest_text = manifest_locator_or_text 1165 if '+R' in self._manifest_text: 1166 self._has_remote_blocks = True 1167 else: 1168 raise errors.ArgumentError( 1169 "Argument to CollectionReader is not a manifest or a collection UUID") 1170 1171 try: 1172 self._populate() 1173 except errors.SyntaxError as e: 1174 raise errors.ArgumentError("Error processing manifest text: %s", str(e)) from None 1175 1176 def storage_classes_desired(self) -> List[str]: 1177 """Get this collection's `storage_classes_desired` value""" 1178 return self._storage_classes_desired or [] 1179 1180 def root_collection(self) -> 'Collection': 1181 return self 1182 1183 def get_properties(self) -> Properties: 1184 """Get this collection's properties 1185 1186 This method always returns a dict. If this collection object does not 1187 have an associated API record, or that record does not have any 1188 properties set, this method returns an empty dict. 1189 """ 1190 if self._api_response and self._api_response["properties"]: 1191 return self._api_response["properties"] 1192 else: 1193 return {} 1194 1195 def get_trash_at(self) -> Optional[datetime.datetime]: 1196 """Get this collection's `trash_at` field 1197 1198 This method parses the `trash_at` field of the collection's API 1199 record and returns a datetime from it. If that field is not set, or 1200 this collection object does not have an associated API record, 1201 returns None. 1202 """ 1203 if self._api_response and self._api_response["trash_at"]: 1204 try: 1205 return ciso8601.parse_datetime(self._api_response["trash_at"]) 1206 except ValueError: 1207 return None 1208 else: 1209 return None 1210 1211 def stream_name(self) -> str: 1212 return "." 1213 1214 def writable(self) -> bool: 1215 return True 1216 1217 @synchronized 1218 @retry_method 1219 def update( 1220 self, 1221 other: Optional['Collection']=None, 1222 num_retries: Optional[int]=None, 1223 ) -> None: 1224 """Merge another collection's contents into this one 1225 1226 This method compares the manifest of this collection instance with 1227 another, then updates this instance's manifest with changes from the 1228 other, renaming files to flag conflicts where necessary. 1229 1230 When called without any arguments, this method reloads the collection's 1231 API record, and updates this instance with any changes that have 1232 appeared server-side. If this instance does not have a corresponding 1233 API record, this method raises `arvados.errors.ArgumentError`. 1234 1235 Arguments: 1236 1237 * other: arvados.collection.Collection | None --- The collection 1238 whose contents should be merged into this instance. When not 1239 provided, this method reloads this collection's API record and 1240 constructs a Collection object from it. If this instance does not 1241 have a corresponding API record, this method raises 1242 `arvados.errors.ArgumentError`. 1243 1244 * num_retries: int | None --- The number of times to retry reloading 1245 the collection's API record from the API server. If not specified, 1246 uses the `num_retries` provided when this instance was constructed. 1247 """ 1248 1249 token_refresh_period = 60*60 1250 time_since_last_token_refresh = (time.time() - self._token_refresh_timestamp) 1251 upstream_response = None 1252 1253 if other is None: 1254 if self._manifest_locator is None: 1255 raise errors.ArgumentError("`other` is None but collection does not have a manifest_locator uuid") 1256 1257 if re.match(arvados.util.portable_data_hash_pattern, self._manifest_locator) and time_since_last_token_refresh < token_refresh_period: 1258 return 1259 1260 upstream_response = self._my_api().collections().get(uuid=self._manifest_locator).execute(num_retries=num_retries) 1261 other = CollectionReader(upstream_response["manifest_text"], self._my_api()) 1262 1263 if self.committed(): 1264 # 1st case, no local changes, content is the same 1265 if self.portable_data_hash() == other.portable_data_hash() and time_since_last_token_refresh < token_refresh_period: 1266 # No difference in content. Remember the API record 1267 # (metadata such as name or properties may have changed) 1268 # but don't update the token refresh timestamp. 1269 if upstream_response is not None: 1270 self._remember_api_response(upstream_response) 1271 return 1272 1273 # 2nd case, no local changes, but either upstream changed 1274 # or we want to refresh tokens. 1275 1276 self.apply(self.diff(other)) 1277 if upstream_response is not None: 1278 self._remember_api_response(upstream_response) 1279 self._update_token_timestamp() 1280 self.set_committed(True) 1281 return 1282 1283 # 3rd case, upstream changed, but we also have uncommitted 1284 # changes that we want to incorporate so they don't get lost. 1285 1286 # _manifest_text stores the text from last time we received a 1287 # record from the API server. This is the state of the 1288 # collection before our uncommitted changes. 1289 baseline = Collection(self._manifest_text, self._my_api()) 1290 1291 # Get the set of changes between our baseline and the other 1292 # collection and apply them to self. 1293 # 1294 # If a file was modified in both 'self' and 'other', the 1295 # 'apply' method keeps the contents of 'self' and creates a 1296 # conflict file with the contents of 'other'. 1297 self.apply(baseline.diff(other)) 1298 1299 # Remember the new baseline, changes to a file 1300 if upstream_response is not None: 1301 self._remember_api_response(upstream_response) 1302 1303 1304 @synchronized 1305 def _my_api(self): 1306 if self._api_client is None: 1307 self._api_client = ThreadSafeAPIClient(self._config, version='v1') 1308 if self._keep_client is None: 1309 self._keep_client = self._api_client.keep 1310 return self._api_client 1311 1312 @synchronized 1313 def _my_keep(self): 1314 if self._keep_client is None: 1315 if self._api_client is None: 1316 self._my_api() 1317 else: 1318 self._keep_client = KeepClient(api_client=self._api_client) 1319 return self._keep_client 1320 1321 @synchronized 1322 def _my_block_manager(self): 1323 if self._block_manager is None: 1324 copies = (self.replication_desired or 1325 self._my_api()._rootDesc.get('defaultCollectionReplication', 1326 2)) 1327 self._block_manager = _BlockManager(self._my_keep(), 1328 copies=copies, 1329 put_threads=self.put_threads, 1330 num_retries=self.num_retries, 1331 storage_classes_func=self.storage_classes_desired) 1332 return self._block_manager 1333 1334 def _remember_api_response(self, response): 1335 self._api_response = response 1336 self._manifest_text = self._api_response['manifest_text'] 1337 self._portable_data_hash = self._api_response['portable_data_hash'] 1338 1339 def _update_token_timestamp(self): 1340 self._token_refresh_timestamp = time.time() 1341 1342 def _populate_from_api_server(self): 1343 # As in KeepClient itself, we must wait until the last 1344 # possible moment to instantiate an API client, in order to 1345 # avoid tripping up clients that don't have access to an API 1346 # server. If we do build one, make sure our Keep client uses 1347 # it. If instantiation fails, we'll fall back to the except 1348 # clause, just like any other Collection lookup 1349 # failure. Return an exception, or None if successful. 1350 self._remember_api_response(self._my_api().collections().get( 1351 uuid=self._manifest_locator).execute( 1352 num_retries=self.num_retries)) 1353 1354 # If not overriden via kwargs, we should try to load the 1355 # replication_desired and storage_classes_desired from the API server 1356 if self.replication_desired is None: 1357 self.replication_desired = self._api_response.get('replication_desired', None) 1358 if self._storage_classes_desired is None: 1359 self._storage_classes_desired = self._api_response.get('storage_classes_desired', None) 1360 1361 def _populate(self): 1362 if self._manifest_text is None: 1363 if self._manifest_locator is None: 1364 return 1365 else: 1366 self._populate_from_api_server() 1367 self._baseline_manifest = self._manifest_text 1368 self._import_manifest(self._manifest_text) 1369 1370 def _has_collection_uuid(self): 1371 return self._manifest_locator is not None and re.match(arvados.util.collection_uuid_pattern, self._manifest_locator) 1372 1373 def _has_local_collection_uuid(self): 1374 return self._has_collection_uuid and \ 1375 self._my_api()._rootDesc['uuidPrefix'] == self._manifest_locator.split('-')[0] 1376 1377 def __enter__(self): 1378 return self 1379 1380 def __exit__(self, exc_type, exc_value, traceback): 1381 """Exit a context with this collection instance 1382 1383 If no exception was raised inside the context block, and this 1384 collection is writable and has a corresponding API record, that 1385 record will be updated to match the state of this instance at the end 1386 of the block. 1387 """ 1388 if exc_type is None: 1389 if self.writable() and self._has_collection_uuid(): 1390 self.save() 1391 self.stop_threads() 1392 1393 def stop_threads(self) -> None: 1394 """Stop background Keep upload/download threads""" 1395 if self._block_manager is not None: 1396 self._block_manager.stop_threads() 1397 1398 @synchronized 1399 def manifest_locator(self) -> Optional[str]: 1400 """Get this collection's manifest locator, if any 1401 1402 * If this collection instance is associated with an API record with a 1403 UUID, return that. 1404 * Otherwise, if this collection instance was loaded from an API record 1405 by portable data hash, return that. 1406 * Otherwise, return `None`. 1407 """ 1408 return self._manifest_locator 1409 1410 @synchronized 1411 def clone( 1412 self, 1413 new_parent: Optional['Collection']=None, 1414 new_name: Optional[str]=None, 1415 readonly: bool=False, 1416 new_config: Optional[Mapping[str, str]]=None, 1417 ) -> 'Collection': 1418 """Create a Collection object with the same contents as this instance 1419 1420 This method creates a new Collection object with contents that match 1421 this instance's. The new collection will not be associated with any API 1422 record. 1423 1424 Arguments: 1425 1426 * new_parent: arvados.collection.Collection | None --- This value is 1427 passed to the new Collection's constructor as the `parent` 1428 argument. 1429 1430 * new_name: str | None --- This value is unused. 1431 1432 * readonly: bool --- If this value is true, this method constructs and 1433 returns a `CollectionReader`. Otherwise, it returns a mutable 1434 `Collection`. Default `False`. 1435 1436 * new_config: Mapping[str, str] | None --- This value is passed to the 1437 new Collection's constructor as `apiconfig`. If no value is provided, 1438 defaults to the configuration passed to this instance's constructor. 1439 """ 1440 if new_config is None: 1441 new_config = self._config 1442 if readonly: 1443 newcollection = CollectionReader(parent=new_parent, apiconfig=new_config) 1444 else: 1445 newcollection = Collection(parent=new_parent, apiconfig=new_config) 1446 1447 newcollection._clonefrom(self) 1448 return newcollection 1449 1450 @synchronized 1451 def api_response(self) -> Optional[Dict[str, Any]]: 1452 """Get this instance's associated API record 1453 1454 If this Collection instance has an associated API record, return it. 1455 Otherwise, return `None`. 1456 """ 1457 return self._api_response 1458 1459 def find_or_create( 1460 self, 1461 path: str, 1462 create_type: CreateType, 1463 ) -> CollectionItem: 1464 if path == ".": 1465 return self 1466 else: 1467 return super(Collection, self).find_or_create(path[2:] if path.startswith("./") else path, create_type) 1468 1469 def find(self, path: str) -> CollectionItem | None: 1470 if path == ".": 1471 return self 1472 else: 1473 return super(Collection, self).find(path[2:] if path.startswith("./") else path) 1474 1475 def remove(self, path: str, recursive: bool=False) -> None: 1476 if path == ".": 1477 raise errors.ArgumentError("Cannot remove '.'") 1478 else: 1479 return super(Collection, self).remove(path[2:] if path.startswith("./") else path, recursive) 1480 1481 @must_be_writable 1482 @synchronized 1483 @retry_method 1484 def save( 1485 self, 1486 properties: Optional[Properties]=None, 1487 storage_classes: Optional[StorageClasses]=None, 1488 trash_at: Optional[datetime.datetime]=None, 1489 merge: bool=True, 1490 num_retries: Optional[int]=None, 1491 preserve_version: bool=False, 1492 ) -> str: 1493 """Save collection to an existing API record 1494 1495 This method updates the instance's corresponding API record to match 1496 the instance's state. If this instance does not have a corresponding API 1497 record yet, raises `AssertionError`. (To create a new API record, use 1498 `Collection.save_new`.) This method returns the saved collection 1499 manifest. 1500 1501 Arguments: 1502 1503 * properties: dict[str, Any] | None --- If provided, the API record will 1504 be updated with these properties. Note this will completely replace 1505 any existing properties. 1506 1507 * storage_classes: list[str] | None --- If provided, the API record will 1508 be updated with this value in the `storage_classes_desired` field. 1509 This value will also be saved on the instance and used for any 1510 changes that follow. 1511 1512 * trash_at: datetime.datetime | None --- If provided, the API record 1513 will be updated with this value in the `trash_at` field. 1514 1515 * merge: bool --- If `True` (the default), this method will first 1516 reload this collection's API record, and merge any new contents into 1517 this instance before saving changes. See `Collection.update` for 1518 details. 1519 1520 * num_retries: int | None --- The number of times to retry reloading 1521 the collection's API record from the API server. If not specified, 1522 uses the `num_retries` provided when this instance was constructed. 1523 1524 * preserve_version: bool --- This value will be passed to directly 1525 to the underlying API call. If `True`, the Arvados API will 1526 preserve the versions of this collection both immediately before 1527 and after the update. If `True` when the API server is not 1528 configured with collection versioning, this method raises 1529 `arvados.errors.ArgumentError`. 1530 """ 1531 if properties and type(properties) is not dict: 1532 raise errors.ArgumentError("properties must be dictionary type.") 1533 1534 if storage_classes and type(storage_classes) is not list: 1535 raise errors.ArgumentError("storage_classes must be list type.") 1536 if storage_classes: 1537 self._storage_classes_desired = storage_classes 1538 1539 if trash_at and type(trash_at) is not datetime.datetime: 1540 raise errors.ArgumentError("trash_at must be datetime type.") 1541 1542 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1543 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1544 1545 body={} 1546 if properties: 1547 body["properties"] = properties 1548 if self.storage_classes_desired(): 1549 body["storage_classes_desired"] = self.storage_classes_desired() 1550 if trash_at: 1551 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1552 body["trash_at"] = t 1553 if preserve_version: 1554 body["preserve_version"] = preserve_version 1555 1556 if not self.committed(): 1557 if self._has_remote_blocks: 1558 # Copy any remote blocks to the local cluster. 1559 self._copy_remote_blocks(remote_blocks={}) 1560 self._has_remote_blocks = False 1561 if not self._has_collection_uuid(): 1562 raise AssertionError("Collection manifest_locator is not a collection uuid. Use save_new() for new collections.") 1563 elif not self._has_local_collection_uuid(): 1564 raise AssertionError("Collection manifest_locator is from a remote cluster. Use save_new() to save it on the local cluster.") 1565 1566 self._my_block_manager().commit_all() 1567 1568 if merge: 1569 self.update() 1570 1571 text = self.manifest_text(strip=False) 1572 body['manifest_text'] = text 1573 1574 self._remember_api_response(self._my_api().collections().update( 1575 uuid=self._manifest_locator, 1576 body=body 1577 ).execute(num_retries=num_retries)) 1578 self.set_committed(True) 1579 elif body: 1580 self._remember_api_response(self._my_api().collections().update( 1581 uuid=self._manifest_locator, 1582 body=body 1583 ).execute(num_retries=num_retries)) 1584 1585 return self._manifest_text 1586 1587 1588 @must_be_writable 1589 @synchronized 1590 @retry_method 1591 def save_new( 1592 self, 1593 name: Optional[str]=None, 1594 create_collection_record: bool=True, 1595 owner_uuid: Optional[str]=None, 1596 properties: Optional[Properties]=None, 1597 storage_classes: Optional[StorageClasses]=None, 1598 trash_at: Optional[datetime.datetime]=None, 1599 ensure_unique_name: bool=False, 1600 num_retries: Optional[int]=None, 1601 preserve_version: bool=False, 1602 ): 1603 """Save collection to a new API record 1604 1605 This method finishes uploading new data blocks and (optionally) 1606 creates a new API collection record with the provided data. If a new 1607 record is created, this instance becomes associated with that record 1608 for future updates like `save()`. This method returns the saved 1609 collection manifest. 1610 1611 Arguments: 1612 1613 * name: str | None --- The `name` field to use on the new collection 1614 record. If not specified, a generic default name is generated. 1615 1616 * create_collection_record: bool --- If `True` (the default), creates a 1617 collection record on the API server. If `False`, the method finishes 1618 all data uploads and only returns the resulting collection manifest 1619 without sending it to the API server. 1620 1621 * owner_uuid: str | None --- The `owner_uuid` field to use on the 1622 new collection record. 1623 1624 * properties: dict[str, Any] | None --- The `properties` field to use on 1625 the new collection record. 1626 1627 * storage_classes: list[str] | None --- The 1628 `storage_classes_desired` field to use on the new collection record. 1629 1630 * trash_at: datetime.datetime | None --- The `trash_at` field to use 1631 on the new collection record. 1632 1633 * ensure_unique_name: bool --- This value is passed directly to the 1634 Arvados API when creating the collection record. If `True`, the API 1635 server may modify the submitted `name` to ensure the collection's 1636 `name`+`owner_uuid` combination is unique. If `False` (the default), 1637 if a collection already exists with this same `name`+`owner_uuid` 1638 combination, creating a collection record will raise a validation 1639 error. 1640 1641 * num_retries: int | None --- The number of times to retry reloading 1642 the collection's API record from the API server. If not specified, 1643 uses the `num_retries` provided when this instance was constructed. 1644 1645 * preserve_version: bool --- This value will be passed to directly 1646 to the underlying API call. If `True`, the Arvados API will 1647 preserve the versions of this collection both immediately before 1648 and after the update. If `True` when the API server is not 1649 configured with collection versioning, this method raises 1650 `arvados.errors.ArgumentError`. 1651 """ 1652 if properties and type(properties) is not dict: 1653 raise errors.ArgumentError("properties must be dictionary type.") 1654 1655 if storage_classes and type(storage_classes) is not list: 1656 raise errors.ArgumentError("storage_classes must be list type.") 1657 1658 if trash_at and type(trash_at) is not datetime.datetime: 1659 raise errors.ArgumentError("trash_at must be datetime type.") 1660 1661 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1662 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1663 1664 if self._has_remote_blocks: 1665 # Copy any remote blocks to the local cluster. 1666 self._copy_remote_blocks(remote_blocks={}) 1667 self._has_remote_blocks = False 1668 1669 if storage_classes: 1670 self._storage_classes_desired = storage_classes 1671 1672 self._my_block_manager().commit_all() 1673 text = self.manifest_text(strip=False) 1674 1675 if create_collection_record: 1676 if name is None: 1677 name = "New collection" 1678 ensure_unique_name = True 1679 1680 body = {"manifest_text": text, 1681 "name": name, 1682 "replication_desired": self.replication_desired} 1683 if owner_uuid: 1684 body["owner_uuid"] = owner_uuid 1685 if properties: 1686 body["properties"] = properties 1687 if self.storage_classes_desired(): 1688 body["storage_classes_desired"] = self.storage_classes_desired() 1689 if trash_at: 1690 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1691 body["trash_at"] = t 1692 if preserve_version: 1693 body["preserve_version"] = preserve_version 1694 1695 self._remember_api_response(self._my_api().collections().create(ensure_unique_name=ensure_unique_name, body=body).execute(num_retries=num_retries)) 1696 self._manifest_locator = self._api_response["uuid"] 1697 self.set_committed(True) 1698 1699 return text 1700 1701 _token_re = re.compile(r'(\S+)(\s+|$)') 1702 _block_re = re.compile(r'[0-9a-f]{32}\+(\d+)(\+\S+)*') 1703 _segment_re = re.compile(r'(\d+):(\d+):(\S+)') 1704 1705 def _unescape_manifest_path(self, path): 1706 return re.sub(r'\\([0-3][0-7][0-7])', lambda m: chr(int(m.group(1), 8)), path) 1707 1708 @synchronized 1709 def _import_manifest(self, manifest_text): 1710 """Import a manifest into a `Collection`. 1711 1712 :manifest_text: 1713 The manifest text to import from. 1714 1715 """ 1716 if len(self) > 0: 1717 raise ArgumentError("Can only import manifest into an empty collection") 1718 1719 STREAM_NAME = 0 1720 BLOCKS = 1 1721 SEGMENTS = 2 1722 1723 stream_name = None 1724 state = STREAM_NAME 1725 1726 for token_and_separator in self._token_re.finditer(manifest_text): 1727 tok = token_and_separator.group(1) 1728 sep = token_and_separator.group(2) 1729 1730 if state == STREAM_NAME: 1731 # starting a new stream 1732 stream_name = self._unescape_manifest_path(tok) 1733 blocks = [] 1734 segments = [] 1735 streamoffset = 0 1736 state = BLOCKS 1737 self.find_or_create(stream_name, COLLECTION) 1738 continue 1739 1740 if state == BLOCKS: 1741 block_locator = self._block_re.match(tok) 1742 if block_locator: 1743 blocksize = int(block_locator.group(1)) 1744 blocks.append(streams.Range(tok, streamoffset, blocksize, 0)) 1745 streamoffset += blocksize 1746 else: 1747 state = SEGMENTS 1748 1749 if state == SEGMENTS: 1750 file_segment = self._segment_re.match(tok) 1751 if file_segment: 1752 pos = int(file_segment.group(1)) 1753 size = int(file_segment.group(2)) 1754 name = self._unescape_manifest_path(file_segment.group(3)) 1755 if name.split('/')[-1] == '.': 1756 # placeholder for persisting an empty directory, not a real file 1757 if len(name) > 2: 1758 self.find_or_create(os.path.join(stream_name, name[:-2]), COLLECTION) 1759 else: 1760 filepath = os.path.join(stream_name, name) 1761 try: 1762 afile = self.find_or_create(filepath, FILE) 1763 except IOError as e: 1764 if e.errno == errno.ENOTDIR: 1765 raise errors.SyntaxError("Dir part of %s conflicts with file of the same name.", filepath) from None 1766 else: 1767 raise e from None 1768 if isinstance(afile, ArvadosFile): 1769 afile.add_segment(blocks, pos, size) 1770 else: 1771 raise errors.SyntaxError("File %s conflicts with stream of the same name.", filepath) 1772 else: 1773 # error! 1774 raise errors.SyntaxError("Invalid manifest format, expected file segment but did not match format: '%s'" % tok) 1775 1776 if sep == "\n": 1777 stream_name = None 1778 state = STREAM_NAME 1779 1780 self._update_token_timestamp() 1781 self.set_committed(True) 1782 1783 @synchronized 1784 def notify( 1785 self, 1786 event: ChangeType, 1787 collection: 'RichCollectionBase', 1788 name: str, 1789 item: CollectionItem, 1790 ) -> None: 1791 if self._callback: 1792 self._callback(event, collection, name, item) 1793 1794 1795class Subcollection(RichCollectionBase): 1796 """Read and manipulate a stream/directory within an Arvados collection 1797 1798 This class represents a single stream (like a directory) within an Arvados 1799 `Collection`. It is returned by `Collection.find` and provides the same API. 1800 Operations that work on the API collection record propagate to the parent 1801 `Collection` object. 1802 """ 1803 1804 def __init__(self, parent, name): 1805 super(Subcollection, self).__init__(parent) 1806 self.lock = self.root_collection().lock 1807 self._manifest_text = None 1808 self.name = name 1809 self.num_retries = parent.num_retries 1810 1811 def root_collection(self) -> 'Collection': 1812 return self.parent.root_collection() 1813 1814 def writable(self) -> bool: 1815 return self.root_collection().writable() 1816 1817 def _my_api(self): 1818 return self.root_collection()._my_api() 1819 1820 def _my_keep(self): 1821 return self.root_collection()._my_keep() 1822 1823 def _my_block_manager(self): 1824 return self.root_collection()._my_block_manager() 1825 1826 def stream_name(self) -> str: 1827 return os.path.join(self.parent.stream_name(), self.name) 1828 1829 @synchronized 1830 def clone( 1831 self, 1832 new_parent: Optional['Collection']=None, 1833 new_name: Optional[str]=None, 1834 ) -> 'Subcollection': 1835 c = Subcollection(new_parent, new_name) 1836 c._clonefrom(self) 1837 return c 1838 1839 @must_be_writable 1840 @synchronized 1841 def _reparent(self, newparent, newname): 1842 self.set_committed(False) 1843 self.flush() 1844 self.parent.remove(self.name, recursive=True) 1845 self.parent = newparent 1846 self.name = newname 1847 self.lock = self.parent.root_collection().lock 1848 1849 @synchronized 1850 def _get_manifest_text(self, stream_name, strip, normalize, only_committed=False): 1851 """Encode empty directories by using an \056-named (".") empty file""" 1852 if len(self._items) == 0: 1853 return "%s %s 0:0:\\056\n" % ( 1854 streams.escape(stream_name), config.EMPTY_BLOCK_LOCATOR) 1855 return super(Subcollection, self)._get_manifest_text(stream_name, 1856 strip, normalize, 1857 only_committed) 1858 1859 1860class CollectionReader(Collection): 1861 """Read-only `Collection` subclass 1862 1863 This class will never create or update any API collection records. You can 1864 use this class for additional code safety when you only need to read 1865 existing collections. 1866 """ 1867 def __init__(self, manifest_locator_or_text, *args, **kwargs): 1868 self._in_init = True 1869 super(CollectionReader, self).__init__(manifest_locator_or_text, *args, **kwargs) 1870 self._in_init = False 1871 1872 # Forego any locking since it should never change once initialized. 1873 self.lock = NoopLock() 1874 1875 # Backwards compatability with old CollectionReader 1876 # all_streams() and all_files() 1877 self._streams = None 1878 1879 def writable(self) -> bool: 1880 return self._in_init
create_type value for Collection.find_or_create
create_type value for Collection.find_or_create
79class CollectionBase(object): 80 """Abstract base class for Collection classes 81 82 .. ATTENTION:: Internal 83 This class is meant to be used by other parts of the SDK. User code 84 should instantiate or subclass `Collection` or one of its subclasses 85 directly. 86 """ 87 88 def __enter__(self): 89 """Enter a context block with this collection instance""" 90 return self 91 92 def __exit__(self, exc_type, exc_value, traceback): 93 """Exit a context block with this collection instance""" 94 pass 95 96 def _my_keep(self): 97 if self._keep_client is None: 98 self._keep_client = KeepClient(api_client=self._api_client, 99 num_retries=self.num_retries) 100 return self._keep_client 101 102 def stripped_manifest(self) -> str: 103 """Create a copy of the collection manifest with only size hints 104 105 This method returns a string with the current collection's manifest 106 text with all non-portable locator hints like permission hints and 107 remote cluster hints removed. The only hints in the returned manifest 108 will be size hints. 109 """ 110 raw = self.manifest_text() 111 clean = [] 112 for line in raw.split("\n"): 113 fields = line.split() 114 if fields: 115 clean_fields = fields[:1] + [ 116 (re.sub(r'\+[^\d][^\+]*', '', x) 117 if re.match(arvados.util.keep_locator_pattern, x) 118 else x) 119 for x in fields[1:]] 120 clean += [' '.join(clean_fields), "\n"] 121 return ''.join(clean)
Abstract base class for Collection classes
102 def stripped_manifest(self) -> str: 103 """Create a copy of the collection manifest with only size hints 104 105 This method returns a string with the current collection's manifest 106 text with all non-portable locator hints like permission hints and 107 remote cluster hints removed. The only hints in the returned manifest 108 will be size hints. 109 """ 110 raw = self.manifest_text() 111 clean = [] 112 for line in raw.split("\n"): 113 fields = line.split() 114 if fields: 115 clean_fields = fields[:1] + [ 116 (re.sub(r'\+[^\d][^\+]*', '', x) 117 if re.match(arvados.util.keep_locator_pattern, x) 118 else x) 119 for x in fields[1:]] 120 clean += [' '.join(clean_fields), "\n"] 121 return ''.join(clean)
Create a copy of the collection manifest with only size hints
This method returns a string with the current collection’s manifest text with all non-portable locator hints like permission hints and remote cluster hints removed. The only hints in the returned manifest will be size hints.
147class RichCollectionBase(CollectionBase): 148 """Base class for Collection classes 149 150 .. ATTENTION:: Internal 151 This class is meant to be used by other parts of the SDK. User code 152 should instantiate or subclass `Collection` or one of its subclasses 153 directly. 154 """ 155 156 def __init__(self, parent=None): 157 self.parent = parent 158 self._committed = False 159 self._has_remote_blocks = False 160 self._callback = None 161 self._items = {} 162 163 def _my_api(self): 164 raise NotImplementedError() 165 166 def _my_keep(self): 167 raise NotImplementedError() 168 169 def _my_block_manager(self): 170 raise NotImplementedError() 171 172 def writable(self) -> bool: 173 """Indicate whether this collection object can be modified 174 175 This method returns `False` if this object is a `CollectionReader`, 176 else `True`. 177 """ 178 raise NotImplementedError() 179 180 def root_collection(self) -> 'Collection': 181 """Get this collection's root collection object 182 183 If you open a subcollection with `Collection.find`, calling this method 184 on that subcollection returns the source Collection object. 185 """ 186 raise NotImplementedError() 187 188 def stream_name(self) -> str: 189 """Get the name of the manifest stream represented by this collection 190 191 If you open a subcollection with `Collection.find`, calling this method 192 on that subcollection returns the name of the stream you opened. 193 """ 194 raise NotImplementedError() 195 196 @synchronized 197 def has_remote_blocks(self) -> bool: 198 """Indiciate whether the collection refers to remote data 199 200 Returns `True` if the collection manifest includes any Keep locators 201 with a remote hint (`+R`), else `False`. 202 """ 203 if self._has_remote_blocks: 204 return True 205 for item in self: 206 if self[item].has_remote_blocks(): 207 return True 208 return False 209 210 @synchronized 211 def set_has_remote_blocks(self, val: bool) -> None: 212 """Cache whether this collection refers to remote blocks 213 214 .. ATTENTION:: Internal 215 This method is only meant to be used by other Collection methods. 216 217 Set this collection's cached "has remote blocks" flag to the given 218 value. 219 """ 220 self._has_remote_blocks = val 221 if self.parent: 222 self.parent.set_has_remote_blocks(val) 223 224 @must_be_writable 225 @synchronized 226 def find_or_create( 227 self, 228 path: str, 229 create_type: CreateType, 230 ) -> CollectionItem: 231 """Get the item at the given path, creating it if necessary 232 233 If `path` refers to a stream in this collection, returns a 234 corresponding `Subcollection` object. If `path` refers to a file in 235 this collection, returns a corresponding 236 `arvados.arvfile.ArvadosFile` object. If `path` does not exist in 237 this collection, then this method creates a new object and returns 238 it, creating parent streams as needed. The type of object created is 239 determined by the value of `create_type`. 240 241 Arguments: 242 243 * path: str --- The path to find or create within this collection. 244 245 * create_type: Literal[COLLECTION, FILE] --- The type of object to 246 create at `path` if one does not exist. Passing `COLLECTION` 247 creates a stream and returns the corresponding 248 `Subcollection`. Passing `FILE` creates a new file and returns the 249 corresponding `arvados.arvfile.ArvadosFile`. 250 """ 251 pathcomponents = path.split("/", 1) 252 if pathcomponents[0]: 253 item = self._items.get(pathcomponents[0]) 254 if len(pathcomponents) == 1: 255 if item is None: 256 # create new file 257 if create_type == COLLECTION: 258 item = Subcollection(self, pathcomponents[0]) 259 else: 260 item = ArvadosFile(self, pathcomponents[0]) 261 self._items[pathcomponents[0]] = item 262 self.set_committed(False) 263 self.notify(ADD, self, pathcomponents[0], item) 264 return item 265 else: 266 if item is None: 267 # create new collection 268 item = Subcollection(self, pathcomponents[0]) 269 self._items[pathcomponents[0]] = item 270 self.set_committed(False) 271 self.notify(ADD, self, pathcomponents[0], item) 272 if isinstance(item, RichCollectionBase): 273 return item.find_or_create(pathcomponents[1], create_type) 274 else: 275 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 276 else: 277 return self 278 279 @synchronized 280 def find(self, path: str) -> CollectionItem | None: 281 """Get the item at the given path. 282 283 If `path` refers to a stream in this collection, returns a 284 corresponding `Subcollection` object. 285 286 As a special case, if `path` is `"."`, returns the collection itself. 287 288 If `path` refers to a file in this collection, returns a corresponding 289 `arvados.arvfile.ArvadosFile` object. 290 291 If `path` does not exist in this collection, then this method returns 292 `None`. 293 294 A `path` that begins with the slash (`/`) character is invalid; 295 `NotADirectoryError` is raised in this case. 296 297 An empty `path` is invalid, and `arvados.errors.ArgumentError` is 298 raised. 299 300 Arguments: 301 302 * path: str --- The path to find within this collection. 303 """ 304 if not path: 305 raise errors.ArgumentError("Parameter 'path' is empty.") 306 307 pathcomponents = path.split("/", 1) 308 if pathcomponents[0] == '': 309 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 310 311 item = self._items.get(pathcomponents[0]) 312 if item is None: 313 return None 314 elif len(pathcomponents) == 1: 315 return item 316 else: 317 if isinstance(item, RichCollectionBase): 318 if pathcomponents[1]: 319 return item.find(pathcomponents[1]) 320 else: 321 return item 322 else: 323 raise IOError( 324 errno.ENOTDIR, "Not a directory", pathcomponents[0] 325 ) 326 327 @synchronized 328 def mkdirs(self, path: str) -> 'Subcollection': 329 """Create and return a subcollection at `path` 330 331 If `path` exists within this collection, raises `FileExistsError`. 332 Otherwise, creates a stream at that path and returns the 333 corresponding `Subcollection`. 334 """ 335 if self.find(path) != None: 336 raise IOError(errno.EEXIST, "Directory or file exists", path) 337 338 return self.find_or_create(path, COLLECTION) 339 340 def open( 341 self, 342 path: str, 343 mode: str="r", 344 encoding: Optional[str]=None 345 ) -> IO: 346 """Open a file-like object within the collection 347 348 This method returns a file-like object that can read and/or write the 349 file located at `path` within the collection. If you attempt to write 350 a `path` that does not exist, the file is created with `find_or_create`. 351 If the file cannot be opened for any other reason, this method raises 352 `OSError` with an appropriate errno. 353 354 Arguments: 355 356 * path: str --- The path of the file to open within this collection 357 358 * mode: str --- The mode to open this file. Supports all the same 359 values as `builtins.open`. 360 361 * encoding: str | None --- The text encoding of the file. Only used 362 when the file is opened in text mode. The default is 363 platform-dependent. 364 365 """ 366 if not re.search(r'^[rwa][bt]?\+?$', mode): 367 raise errors.ArgumentError("Invalid mode {!r}".format(mode)) 368 369 if mode[0] == 'r' and '+' not in mode: 370 fclass = ArvadosFileReader 371 arvfile = self.find(path) 372 elif not self.writable(): 373 raise IOError(errno.EROFS, "Collection is read only") 374 else: 375 fclass = ArvadosFileWriter 376 arvfile = self.find_or_create(path, FILE) 377 378 if arvfile is None: 379 raise IOError(errno.ENOENT, "File not found", path) 380 if not isinstance(arvfile, ArvadosFile): 381 raise IOError(errno.EISDIR, "Is a directory", path) 382 383 if mode[0] == 'w': 384 arvfile.truncate(0) 385 386 binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:]) 387 f = fclass(arvfile, mode=binmode, num_retries=self.num_retries) 388 if 'b' not in mode: 389 bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader 390 f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding) 391 return f 392 393 def modified(self) -> bool: 394 """Indicate whether this collection has an API server record 395 396 Returns `False` if this collection corresponds to a record loaded from 397 the API server, `True` otherwise. 398 """ 399 return not self.committed() 400 401 @synchronized 402 def committed(self): 403 """Indicate whether this collection has an API server record 404 405 Returns `True` if this collection corresponds to a record loaded from 406 the API server, `False` otherwise. 407 """ 408 return self._committed 409 410 @synchronized 411 def set_committed(self, value: bool=True): 412 """Cache whether this collection has an API server record 413 414 .. ATTENTION:: Internal 415 This method is only meant to be used by other Collection methods. 416 417 Set this collection's cached "committed" flag to the given 418 value and propagates it as needed. 419 """ 420 if value == self._committed: 421 return 422 if value: 423 for k,v in self._items.items(): 424 v.set_committed(True) 425 self._committed = True 426 else: 427 self._committed = False 428 if self.parent is not None: 429 self.parent.set_committed(False) 430 431 @synchronized 432 def __iter__(self) -> Iterator[str]: 433 """Iterate names of streams and files in this collection 434 435 This method does not recurse. It only iterates the contents of this 436 collection's corresponding stream. 437 """ 438 return iter(self._items) 439 440 @synchronized 441 def __getitem__(self, k: str) -> CollectionItem: 442 """Get a `arvados.arvfile.ArvadosFile` or `Subcollection` in this collection 443 444 This method does not recurse. If you want to search a path, use 445 `RichCollectionBase.find` instead. 446 """ 447 return self._items[k] 448 449 @synchronized 450 def __contains__(self, k: str) -> bool: 451 """Indicate whether this collection has an item with this name 452 453 This method does not recurse. It you want to check a path, use 454 `RichCollectionBase.exists` instead. 455 """ 456 return k in self._items 457 458 @synchronized 459 def __len__(self): 460 """Get the number of items directly contained in this collection 461 462 This method does not recurse. It only counts the streams and files 463 in this collection's corresponding stream. 464 """ 465 return len(self._items) 466 467 @must_be_writable 468 @synchronized 469 def __delitem__(self, p: str) -> None: 470 """Delete an item from this collection's stream 471 472 This method does not recurse. If you want to remove an item by a 473 path, use `RichCollectionBase.remove` instead. 474 """ 475 del self._items[p] 476 self.set_committed(False) 477 self.notify(DEL, self, p, None) 478 479 @synchronized 480 def keys(self) -> Iterator[str]: 481 """Iterate names of streams and files in this collection 482 483 This method does not recurse. It only iterates the contents of this 484 collection's corresponding stream. 485 """ 486 return self._items.keys() 487 488 @synchronized 489 def values(self) -> List[CollectionItem]: 490 """Get a list of objects in this collection's stream 491 492 The return value includes a `Subcollection` for every stream, and an 493 `arvados.arvfile.ArvadosFile` for every file, directly within this 494 collection's stream. This method does not recurse. 495 """ 496 return list(self._items.values()) 497 498 @synchronized 499 def items(self) -> List[Tuple[str, CollectionItem]]: 500 """Get a list of `(name, object)` tuples from this collection's stream 501 502 The return value includes a `Subcollection` for every stream, and an 503 `arvados.arvfile.ArvadosFile` for every file, directly within this 504 collection's stream. This method does not recurse. 505 """ 506 return list(self._items.items()) 507 508 def exists(self, path: str) -> bool: 509 """Indicate whether this collection includes an item at `path` 510 511 This method returns `True` if `path` refers to a stream or file within 512 this collection, else `False`. 513 514 Arguments: 515 516 * path: str --- The path to check for existence within this collection 517 """ 518 return self.find(path) is not None 519 520 @must_be_writable 521 @synchronized 522 def remove(self, path: str, recursive: bool=False) -> None: 523 """Remove the file or stream at `path` 524 525 Arguments: 526 527 * path: str --- The path of the item to remove from the collection 528 529 * recursive: bool --- Controls the method's behavior if `path` refers 530 to a nonempty stream. If `False` (the default), this method raises 531 `OSError` with errno `ENOTEMPTY`. If `True`, this method removes all 532 items under the stream. 533 """ 534 if not path: 535 raise errors.ArgumentError("Parameter 'path' is empty.") 536 537 pathcomponents = path.split("/", 1) 538 item = self._items.get(pathcomponents[0]) 539 if item is None: 540 raise IOError(errno.ENOENT, "File not found", path) 541 if len(pathcomponents) == 1: 542 if isinstance(self._items[pathcomponents[0]], RichCollectionBase) and len(self._items[pathcomponents[0]]) > 0 and not recursive: 543 raise IOError(errno.ENOTEMPTY, "Directory not empty", path) 544 deleteditem = self._items[pathcomponents[0]] 545 del self._items[pathcomponents[0]] 546 self.set_committed(False) 547 self.notify(DEL, self, pathcomponents[0], deleteditem) 548 else: 549 item.remove(pathcomponents[1], recursive=recursive) 550 551 def _clonefrom(self, source): 552 for k,v in source.items(): 553 self._items[k] = v.clone(self, k) 554 555 def clone(self): 556 raise NotImplementedError() 557 558 @must_be_writable 559 @synchronized 560 def add( 561 self, 562 source_obj: CollectionItem, 563 target_name: str, 564 overwrite: bool=False, 565 reparent: bool=False, 566 ) -> None: 567 """Copy or move a file or subcollection object to this collection 568 569 Arguments: 570 571 * source_obj: arvados.arvfile.ArvadosFile | Subcollection --- The file or subcollection 572 to add to this collection 573 574 * target_name: str --- The path inside this collection where 575 `source_obj` should be added. 576 577 * overwrite: bool --- Controls the behavior of this method when the 578 collection already contains an object at `target_name`. If `False` 579 (the default), this method will raise `FileExistsError`. If `True`, 580 the object at `target_name` will be replaced with `source_obj`. 581 582 * reparent: bool --- Controls whether this method copies or moves 583 `source_obj`. If `False` (the default), `source_obj` is copied into 584 this collection. If `True`, `source_obj` is moved into this 585 collection. 586 """ 587 if target_name in self and not overwrite: 588 raise IOError(errno.EEXIST, "File already exists", target_name) 589 590 modified_from = None 591 if target_name in self: 592 modified_from = self[target_name] 593 594 # Actually make the move or copy. 595 if reparent: 596 source_obj._reparent(self, target_name) 597 item = source_obj 598 else: 599 item = source_obj.clone(self, target_name) 600 601 self._items[target_name] = item 602 self.set_committed(False) 603 if not self._has_remote_blocks and source_obj.has_remote_blocks(): 604 self.set_has_remote_blocks(True) 605 606 if modified_from: 607 self.notify(MOD, self, target_name, (modified_from, item)) 608 else: 609 self.notify(ADD, self, target_name, item) 610 611 def _get_src_target(self, source, target_path, source_collection, create_dest): 612 if source_collection is None: 613 source_collection = self 614 615 # Find the object 616 if isinstance(source, str): 617 source_obj = source_collection.find(source) 618 if source_obj is None: 619 raise IOError(errno.ENOENT, "File not found", source) 620 sourcecomponents = source.split("/") 621 else: 622 source_obj = source 623 sourcecomponents = None 624 625 # Find parent collection the target path 626 targetcomponents = target_path.split("/") 627 628 # Determine the name to use. 629 target_name = targetcomponents[-1] if targetcomponents[-1] else sourcecomponents[-1] 630 631 if not target_name: 632 raise errors.ArgumentError("Target path is empty and source is an object. Cannot determine destination filename to use.") 633 634 if create_dest: 635 target_dir = self.find_or_create("/".join(targetcomponents[0:-1]), COLLECTION) 636 else: 637 if len(targetcomponents) > 1: 638 target_dir = self.find("/".join(targetcomponents[0:-1])) 639 else: 640 target_dir = self 641 642 if target_dir is None: 643 raise IOError(errno.ENOENT, "Target directory not found", target_name) 644 645 if target_name in target_dir and isinstance(target_dir[target_name], RichCollectionBase) and sourcecomponents: 646 target_dir = target_dir[target_name] 647 target_name = sourcecomponents[-1] 648 649 return (source_obj, target_dir, target_name) 650 651 @must_be_writable 652 @synchronized 653 def copy( 654 self, 655 source: Union[str, CollectionItem], 656 target_path: str, 657 source_collection: Optional['RichCollectionBase']=None, 658 overwrite: bool=False, 659 ) -> None: 660 """Copy a file or subcollection object to this collection 661 662 Arguments: 663 664 * source: str | arvados.arvfile.ArvadosFile | 665 arvados.collection.Subcollection --- The file or subcollection to 666 add to this collection. If `source` is a str, the object will be 667 found by looking up this path from `source_collection` (see 668 below). 669 670 * target_path: str --- The path inside this collection where the 671 source object should be added. 672 673 * source_collection: arvados.collection.Collection | None --- The 674 collection to find the source object from when `source` is a 675 path. Defaults to the current collection (`self`). 676 677 * overwrite: bool --- Controls the behavior of this method when the 678 collection already contains an object at `target_path`. If `False` 679 (the default), this method will raise `FileExistsError`. If `True`, 680 the object at `target_path` will be replaced with `source_obj`. 681 """ 682 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, True) 683 target_dir.add(source_obj, target_name, overwrite, False) 684 685 @must_be_writable 686 @synchronized 687 def rename( 688 self, 689 source: Union[str, CollectionItem], 690 target_path: str, 691 source_collection: Optional['RichCollectionBase']=None, 692 overwrite: bool=False, 693 ) -> None: 694 """Move a file or subcollection object to this collection 695 696 Arguments: 697 698 * source: str | arvados.arvfile.ArvadosFile | 699 arvados.collection.Subcollection --- The file or subcollection to 700 add to this collection. If `source` is a str, the object will be 701 found by looking up this path from `source_collection` (see 702 below). 703 704 * target_path: str --- The path inside this collection where the 705 source object should be added. 706 707 * source_collection: arvados.collection.Collection | None --- The 708 collection to find the source object from when `source` is a 709 path. Defaults to the current collection (`self`). 710 711 * overwrite: bool --- Controls the behavior of this method when the 712 collection already contains an object at `target_path`. If `False` 713 (the default), this method will raise `FileExistsError`. If `True`, 714 the object at `target_path` will be replaced with `source_obj`. 715 """ 716 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, False) 717 if not source_obj.writable(): 718 raise IOError(errno.EROFS, "Source collection is read only", source) 719 target_dir.add(source_obj, target_name, overwrite, True) 720 721 def portable_manifest_text(self, stream_name: str=".") -> str: 722 """Get the portable manifest text for this collection 723 724 The portable manifest text is normalized, and does not include access 725 tokens. This method does not flush outstanding blocks to Keep. 726 727 Arguments: 728 729 * stream_name: str --- The name to use for this collection's stream in 730 the generated manifest. Default `'.'`. 731 """ 732 return self._get_manifest_text(stream_name, True, True) 733 734 @synchronized 735 def manifest_text( 736 self, 737 stream_name: str=".", 738 strip: bool=False, 739 normalize: bool=False, 740 only_committed: bool=False, 741 ) -> str: 742 """Get the manifest text for this collection 743 744 Arguments: 745 746 * stream_name: str --- The name to use for this collection's stream in 747 the generated manifest. Default `'.'`. 748 749 * strip: bool --- Controls whether or not the returned manifest text 750 includes access tokens. If `False` (the default), the manifest text 751 will include access tokens. If `True`, the manifest text will not 752 include access tokens. 753 754 * normalize: bool --- Controls whether or not the returned manifest 755 text is normalized. Default `False`. 756 757 * only_committed: bool --- Controls whether or not this method uploads 758 pending data to Keep before building and returning the manifest text. 759 If `False` (the default), this method will finish uploading all data 760 to Keep, then return the final manifest. If `True`, this method will 761 build and return a manifest that only refers to the data that has 762 finished uploading at the time this method was called. 763 """ 764 if not only_committed: 765 self._my_block_manager().commit_all() 766 return self._get_manifest_text(stream_name, strip, normalize, 767 only_committed=only_committed) 768 769 @synchronized 770 def _get_manifest_text(self, stream_name, strip, normalize, only_committed=False): 771 """Get the manifest text for this collection, sub collections and files. 772 773 :stream_name: 774 Name to use for this stream (directory) 775 776 :strip: 777 If True, remove signing tokens from block locators if present. 778 If False (default), block locators are left unchanged. 779 780 :normalize: 781 If True, always export the manifest text in normalized form 782 even if the Collection is not modified. If False (default) and the collection 783 is not modified, return the original manifest text even if it is not 784 in normalized form. 785 786 :only_committed: 787 If True, only include blocks that were already committed to Keep. 788 789 """ 790 791 if not self.committed() or self._manifest_text is None or normalize: 792 stream = {} 793 buf = [] 794 sorted_keys = sorted(self.keys()) 795 for filename in [s for s in sorted_keys if isinstance(self[s], ArvadosFile)]: 796 # Create a stream per file `k` 797 arvfile = self[filename] 798 filestream = [] 799 for segment in arvfile.segments(): 800 loc = segment.locator 801 if arvfile.parent._my_block_manager().is_bufferblock(loc): 802 if only_committed: 803 continue 804 loc = arvfile.parent._my_block_manager().get_bufferblock(loc).locator() 805 if strip: 806 loc = KeepLocator(loc).stripped() 807 filestream.append(streams.LocatorAndRange( 808 loc, 809 KeepLocator(loc).size, 810 segment.segment_offset, 811 segment.range_size, 812 )) 813 stream[filename] = filestream 814 if stream: 815 buf.append(" ".join(streams.normalize_stream(stream_name, stream)) + "\n") 816 for dirname in [s for s in sorted_keys if isinstance(self[s], RichCollectionBase)]: 817 buf.append(self[dirname].manifest_text( 818 stream_name=os.path.join(stream_name, dirname), 819 strip=strip, normalize=True, only_committed=only_committed)) 820 return "".join(buf) 821 else: 822 if strip: 823 return self.stripped_manifest() 824 else: 825 return self._manifest_text 826 827 @synchronized 828 def _copy_remote_blocks(self, remote_blocks={}): 829 """Scan through the entire collection and ask Keep to copy remote blocks. 830 831 When accessing a remote collection, blocks will have a remote signature 832 (+R instead of +A). Collect these signatures and request Keep to copy the 833 blocks to the local cluster, returning local (+A) signatures. 834 835 :remote_blocks: 836 Shared cache of remote to local block mappings. This is used to avoid 837 doing extra work when blocks are shared by more than one file in 838 different subdirectories. 839 840 """ 841 for item in self: 842 remote_blocks = self[item]._copy_remote_blocks(remote_blocks) 843 return remote_blocks 844 845 @synchronized 846 def diff( 847 self, 848 end_collection: 'RichCollectionBase', 849 prefix: str=".", 850 holding_collection: Optional['Collection']=None, 851 ) -> ChangeList: 852 """Build a list of differences between this collection and another 853 854 Arguments: 855 856 * end_collection: arvados.collection.RichCollectionBase --- A 857 collection object with the desired end state. The returned diff 858 list will describe how to go from the current collection object 859 `self` to `end_collection`. 860 861 * prefix: str --- The name to use for this collection's stream in 862 the diff list. Default `'.'`. 863 864 * holding_collection: arvados.collection.Collection | None --- A 865 collection object used to hold objects for the returned diff 866 list. By default, a new empty collection is created. 867 """ 868 changes = [] 869 if holding_collection is None: 870 holding_collection = Collection(api_client=self._my_api(), keep_client=self._my_keep()) 871 for k in self: 872 if k not in end_collection: 873 changes.append((DEL, os.path.join(prefix, k), self[k].clone(holding_collection, ""))) 874 for k in end_collection: 875 if k in self: 876 if isinstance(end_collection[k], Subcollection) and isinstance(self[k], Subcollection): 877 changes.extend(self[k].diff(end_collection[k], os.path.join(prefix, k), holding_collection)) 878 elif end_collection[k] != self[k]: 879 changes.append((MOD, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 880 else: 881 changes.append((TOK, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 882 else: 883 changes.append((ADD, os.path.join(prefix, k), end_collection[k].clone(holding_collection, ""))) 884 return changes 885 886 @must_be_writable 887 @synchronized 888 def apply(self, changes: ChangeList) -> None: 889 """Apply a list of changes from to this collection 890 891 This method takes a list of changes generated by 892 `RichCollectionBase.diff` and applies it to this 893 collection. Afterward, the state of this collection object will 894 match the state of `end_collection` passed to `diff`. If a change 895 conflicts with a local change, it will be saved to an alternate path 896 indicating the conflict. 897 898 Arguments: 899 900 * changes: arvados.collection.ChangeList --- The list of differences 901 generated by `RichCollectionBase.diff`. 902 """ 903 if changes: 904 self.set_committed(False) 905 for change in changes: 906 event_type = change[0] 907 path = change[1] 908 initial = change[2] 909 local = self.find(path) 910 conflictpath = "%s~%s~conflict~" % (path, time.strftime("%Y%m%d-%H%M%S", 911 time.gmtime())) 912 if event_type == ADD: 913 if local is None: 914 # No local file at path, safe to copy over new file 915 self.copy(initial, path) 916 elif local is not None and local != initial: 917 # There is already local file and it is different: 918 # save change to conflict file. 919 self.copy(initial, conflictpath) 920 elif event_type == MOD or event_type == TOK: 921 final = change[3] 922 if local == initial: 923 # Local matches the "initial" item so it has not 924 # changed locally and is safe to update. 925 if isinstance(local, ArvadosFile) and isinstance(final, ArvadosFile): 926 # Replace contents of local file with new contents 927 local.replace_contents(final) 928 else: 929 # Overwrite path with new item; this can happen if 930 # path was a file and is now a collection or vice versa 931 self.copy(final, path, overwrite=True) 932 elif event_type == MOD: 933 # Local doesn't match the "start" value or local 934 # is missing (presumably deleted) so save change 935 # to conflict file. Don't do this for TOK events 936 # which means the file didn't change but only had 937 # tokens updated. 938 self.copy(final, conflictpath) 939 elif event_type == DEL: 940 if local == initial: 941 # Local item matches "initial" value, so it is safe to remove. 942 self.remove(path, recursive=True) 943 # else, the file is modified or already removed, in either 944 # case we don't want to try to remove it. 945 946 def portable_data_hash(self) -> str: 947 """Get the portable data hash for this collection's manifest""" 948 if self._manifest_locator and self.committed(): 949 # If the collection is already saved on the API server, and it's committed 950 # then return API server's PDH response. 951 return self._portable_data_hash 952 else: 953 stripped = self.portable_manifest_text().encode() 954 return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped)) 955 956 @synchronized 957 def subscribe(self, callback: ChangeCallback) -> None: 958 """Set a notify callback for changes to this collection 959 960 Arguments: 961 962 * callback: arvados.collection.ChangeCallback --- The callable to 963 call each time the collection is changed. 964 """ 965 if self._callback is None: 966 self._callback = callback 967 else: 968 raise errors.ArgumentError("A callback is already set on this collection.") 969 970 @synchronized 971 def unsubscribe(self) -> None: 972 """Remove any notify callback set for changes to this collection""" 973 if self._callback is not None: 974 self._callback = None 975 976 @synchronized 977 def notify( 978 self, 979 event: ChangeType, 980 collection: 'RichCollectionBase', 981 name: str, 982 item: CollectionItem, 983 ) -> None: 984 """Notify any subscribed callback about a change to this collection 985 986 .. ATTENTION:: Internal 987 This method is only meant to be used by other Collection methods. 988 989 If a callback has been registered with `RichCollectionBase.subscribe`, 990 it will be called with information about a change to this collection. 991 Then this notification will be propagated to this collection's root. 992 993 Arguments: 994 995 * event: Literal[ADD, DEL, MOD, TOK] --- The type of modification to 996 the collection. 997 998 * collection: arvados.collection.RichCollectionBase --- The 999 collection that was modified. 1000 1001 * name: str --- The name of the file or stream within `collection` that 1002 was modified. 1003 1004 * item: arvados.arvfile.ArvadosFile | 1005 arvados.collection.Subcollection --- For ADD events, the new 1006 contents at `name` within `collection`; for DEL events, the 1007 item that was removed. For MOD and TOK events, a 2-tuple of 1008 the previous item and the new item (may be the same object 1009 or different, depending on whether the action involved it 1010 being modified in place or replaced). 1011 1012 """ 1013 if self._callback: 1014 self._callback(event, collection, name, item) 1015 self.root_collection().notify(event, collection, name, item) 1016 1017 @synchronized 1018 def __eq__(self, other: Any) -> bool: 1019 """Indicate whether this collection object is equal to another""" 1020 if other is self: 1021 return True 1022 if not isinstance(other, RichCollectionBase): 1023 return False 1024 if len(self._items) != len(other): 1025 return False 1026 for k in self._items: 1027 if k not in other: 1028 return False 1029 if self._items[k] != other[k]: 1030 return False 1031 return True 1032 1033 def __ne__(self, other: Any) -> bool: 1034 """Indicate whether this collection object is not equal to another""" 1035 return not self.__eq__(other) 1036 1037 @synchronized 1038 def flush(self) -> None: 1039 """Upload any pending data to Keep""" 1040 for e in self.values(): 1041 e.flush()
Base class for Collection classes
172 def writable(self) -> bool: 173 """Indicate whether this collection object can be modified 174 175 This method returns `False` if this object is a `CollectionReader`, 176 else `True`. 177 """ 178 raise NotImplementedError()
Indicate whether this collection object can be modified
This method returns False if this object is a CollectionReader,
else True.
180 def root_collection(self) -> 'Collection': 181 """Get this collection's root collection object 182 183 If you open a subcollection with `Collection.find`, calling this method 184 on that subcollection returns the source Collection object. 185 """ 186 raise NotImplementedError()
Get this collection’s root collection object
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the source Collection object.
188 def stream_name(self) -> str: 189 """Get the name of the manifest stream represented by this collection 190 191 If you open a subcollection with `Collection.find`, calling this method 192 on that subcollection returns the name of the stream you opened. 193 """ 194 raise NotImplementedError()
Get the name of the manifest stream represented by this collection
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the name of the stream you opened.
196 @synchronized 197 def has_remote_blocks(self) -> bool: 198 """Indiciate whether the collection refers to remote data 199 200 Returns `True` if the collection manifest includes any Keep locators 201 with a remote hint (`+R`), else `False`. 202 """ 203 if self._has_remote_blocks: 204 return True 205 for item in self: 206 if self[item].has_remote_blocks(): 207 return True 208 return False
Indiciate whether the collection refers to remote data
Returns True if the collection manifest includes any Keep locators
with a remote hint (+R), else False.
210 @synchronized 211 def set_has_remote_blocks(self, val: bool) -> None: 212 """Cache whether this collection refers to remote blocks 213 214 .. ATTENTION:: Internal 215 This method is only meant to be used by other Collection methods. 216 217 Set this collection's cached "has remote blocks" flag to the given 218 value. 219 """ 220 self._has_remote_blocks = val 221 if self.parent: 222 self.parent.set_has_remote_blocks(val)
Cache whether this collection refers to remote blocks
Set this collection’s cached “has remote blocks” flag to the given value.
224 @must_be_writable 225 @synchronized 226 def find_or_create( 227 self, 228 path: str, 229 create_type: CreateType, 230 ) -> CollectionItem: 231 """Get the item at the given path, creating it if necessary 232 233 If `path` refers to a stream in this collection, returns a 234 corresponding `Subcollection` object. If `path` refers to a file in 235 this collection, returns a corresponding 236 `arvados.arvfile.ArvadosFile` object. If `path` does not exist in 237 this collection, then this method creates a new object and returns 238 it, creating parent streams as needed. The type of object created is 239 determined by the value of `create_type`. 240 241 Arguments: 242 243 * path: str --- The path to find or create within this collection. 244 245 * create_type: Literal[COLLECTION, FILE] --- The type of object to 246 create at `path` if one does not exist. Passing `COLLECTION` 247 creates a stream and returns the corresponding 248 `Subcollection`. Passing `FILE` creates a new file and returns the 249 corresponding `arvados.arvfile.ArvadosFile`. 250 """ 251 pathcomponents = path.split("/", 1) 252 if pathcomponents[0]: 253 item = self._items.get(pathcomponents[0]) 254 if len(pathcomponents) == 1: 255 if item is None: 256 # create new file 257 if create_type == COLLECTION: 258 item = Subcollection(self, pathcomponents[0]) 259 else: 260 item = ArvadosFile(self, pathcomponents[0]) 261 self._items[pathcomponents[0]] = item 262 self.set_committed(False) 263 self.notify(ADD, self, pathcomponents[0], item) 264 return item 265 else: 266 if item is None: 267 # create new collection 268 item = Subcollection(self, pathcomponents[0]) 269 self._items[pathcomponents[0]] = item 270 self.set_committed(False) 271 self.notify(ADD, self, pathcomponents[0], item) 272 if isinstance(item, RichCollectionBase): 273 return item.find_or_create(pathcomponents[1], create_type) 274 else: 275 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 276 else: 277 return self
Get the item at the given path, creating it if necessary
If path refers to a stream in this collection, returns a
corresponding Subcollection object. If path refers to a file in
this collection, returns a corresponding
arvados.arvfile.ArvadosFile object. If path does not exist in
this collection, then this method creates a new object and returns
it, creating parent streams as needed. The type of object created is
determined by the value of create_type.
Arguments:
path: str — The path to find or create within this collection.
create_type: Literal[COLLECTION, FILE] — The type of object to create at
pathif one does not exist. PassingCOLLECTIONcreates a stream and returns the correspondingSubcollection. PassingFILEcreates a new file and returns the correspondingarvados.arvfile.ArvadosFile.
279 @synchronized 280 def find(self, path: str) -> CollectionItem | None: 281 """Get the item at the given path. 282 283 If `path` refers to a stream in this collection, returns a 284 corresponding `Subcollection` object. 285 286 As a special case, if `path` is `"."`, returns the collection itself. 287 288 If `path` refers to a file in this collection, returns a corresponding 289 `arvados.arvfile.ArvadosFile` object. 290 291 If `path` does not exist in this collection, then this method returns 292 `None`. 293 294 A `path` that begins with the slash (`/`) character is invalid; 295 `NotADirectoryError` is raised in this case. 296 297 An empty `path` is invalid, and `arvados.errors.ArgumentError` is 298 raised. 299 300 Arguments: 301 302 * path: str --- The path to find within this collection. 303 """ 304 if not path: 305 raise errors.ArgumentError("Parameter 'path' is empty.") 306 307 pathcomponents = path.split("/", 1) 308 if pathcomponents[0] == '': 309 raise IOError(errno.ENOTDIR, "Not a directory", pathcomponents[0]) 310 311 item = self._items.get(pathcomponents[0]) 312 if item is None: 313 return None 314 elif len(pathcomponents) == 1: 315 return item 316 else: 317 if isinstance(item, RichCollectionBase): 318 if pathcomponents[1]: 319 return item.find(pathcomponents[1]) 320 else: 321 return item 322 else: 323 raise IOError( 324 errno.ENOTDIR, "Not a directory", pathcomponents[0] 325 )
Get the item at the given path.
If path refers to a stream in this collection, returns a
corresponding Subcollection object.
As a special case, if path is ".", returns the collection itself.
If path refers to a file in this collection, returns a corresponding
arvados.arvfile.ArvadosFile object.
If path does not exist in this collection, then this method returns
None.
A path that begins with the slash (/) character is invalid;
NotADirectoryError is raised in this case.
An empty path is invalid, and arvados.errors.ArgumentError is
raised.
Arguments:
- path: str — The path to find within this collection.
327 @synchronized 328 def mkdirs(self, path: str) -> 'Subcollection': 329 """Create and return a subcollection at `path` 330 331 If `path` exists within this collection, raises `FileExistsError`. 332 Otherwise, creates a stream at that path and returns the 333 corresponding `Subcollection`. 334 """ 335 if self.find(path) != None: 336 raise IOError(errno.EEXIST, "Directory or file exists", path) 337 338 return self.find_or_create(path, COLLECTION)
Create and return a subcollection at path
If path exists within this collection, raises FileExistsError.
Otherwise, creates a stream at that path and returns the
corresponding Subcollection.
340 def open( 341 self, 342 path: str, 343 mode: str="r", 344 encoding: Optional[str]=None 345 ) -> IO: 346 """Open a file-like object within the collection 347 348 This method returns a file-like object that can read and/or write the 349 file located at `path` within the collection. If you attempt to write 350 a `path` that does not exist, the file is created with `find_or_create`. 351 If the file cannot be opened for any other reason, this method raises 352 `OSError` with an appropriate errno. 353 354 Arguments: 355 356 * path: str --- The path of the file to open within this collection 357 358 * mode: str --- The mode to open this file. Supports all the same 359 values as `builtins.open`. 360 361 * encoding: str | None --- The text encoding of the file. Only used 362 when the file is opened in text mode. The default is 363 platform-dependent. 364 365 """ 366 if not re.search(r'^[rwa][bt]?\+?$', mode): 367 raise errors.ArgumentError("Invalid mode {!r}".format(mode)) 368 369 if mode[0] == 'r' and '+' not in mode: 370 fclass = ArvadosFileReader 371 arvfile = self.find(path) 372 elif not self.writable(): 373 raise IOError(errno.EROFS, "Collection is read only") 374 else: 375 fclass = ArvadosFileWriter 376 arvfile = self.find_or_create(path, FILE) 377 378 if arvfile is None: 379 raise IOError(errno.ENOENT, "File not found", path) 380 if not isinstance(arvfile, ArvadosFile): 381 raise IOError(errno.EISDIR, "Is a directory", path) 382 383 if mode[0] == 'w': 384 arvfile.truncate(0) 385 386 binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:]) 387 f = fclass(arvfile, mode=binmode, num_retries=self.num_retries) 388 if 'b' not in mode: 389 bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader 390 f = io.TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding) 391 return f
Open a file-like object within the collection
This method returns a file-like object that can read and/or write the
file located at path within the collection. If you attempt to write
a path that does not exist, the file is created with find_or_create.
If the file cannot be opened for any other reason, this method raises
OSError with an appropriate errno.
Arguments:
path: str — The path of the file to open within this collection
mode: str — The mode to open this file. Supports all the same values as
builtins.open.encoding: str | None — The text encoding of the file. Only used when the file is opened in text mode. The default is platform-dependent.
393 def modified(self) -> bool: 394 """Indicate whether this collection has an API server record 395 396 Returns `False` if this collection corresponds to a record loaded from 397 the API server, `True` otherwise. 398 """ 399 return not self.committed()
Indicate whether this collection has an API server record
Returns False if this collection corresponds to a record loaded from
the API server, True otherwise.
401 @synchronized 402 def committed(self): 403 """Indicate whether this collection has an API server record 404 405 Returns `True` if this collection corresponds to a record loaded from 406 the API server, `False` otherwise. 407 """ 408 return self._committed
Indicate whether this collection has an API server record
Returns True if this collection corresponds to a record loaded from
the API server, False otherwise.
410 @synchronized 411 def set_committed(self, value: bool=True): 412 """Cache whether this collection has an API server record 413 414 .. ATTENTION:: Internal 415 This method is only meant to be used by other Collection methods. 416 417 Set this collection's cached "committed" flag to the given 418 value and propagates it as needed. 419 """ 420 if value == self._committed: 421 return 422 if value: 423 for k,v in self._items.items(): 424 v.set_committed(True) 425 self._committed = True 426 else: 427 self._committed = False 428 if self.parent is not None: 429 self.parent.set_committed(False)
Cache whether this collection has an API server record
Set this collection’s cached “committed” flag to the given value and propagates it as needed.
479 @synchronized 480 def keys(self) -> Iterator[str]: 481 """Iterate names of streams and files in this collection 482 483 This method does not recurse. It only iterates the contents of this 484 collection's corresponding stream. 485 """ 486 return self._items.keys()
Iterate names of streams and files in this collection
This method does not recurse. It only iterates the contents of this collection’s corresponding stream.
488 @synchronized 489 def values(self) -> List[CollectionItem]: 490 """Get a list of objects in this collection's stream 491 492 The return value includes a `Subcollection` for every stream, and an 493 `arvados.arvfile.ArvadosFile` for every file, directly within this 494 collection's stream. This method does not recurse. 495 """ 496 return list(self._items.values())
Get a list of objects in this collection’s stream
The return value includes a Subcollection for every stream, and an
arvados.arvfile.ArvadosFile for every file, directly within this
collection’s stream. This method does not recurse.
498 @synchronized 499 def items(self) -> List[Tuple[str, CollectionItem]]: 500 """Get a list of `(name, object)` tuples from this collection's stream 501 502 The return value includes a `Subcollection` for every stream, and an 503 `arvados.arvfile.ArvadosFile` for every file, directly within this 504 collection's stream. This method does not recurse. 505 """ 506 return list(self._items.items())
Get a list of (name, object) tuples from this collection’s stream
The return value includes a Subcollection for every stream, and an
arvados.arvfile.ArvadosFile for every file, directly within this
collection’s stream. This method does not recurse.
508 def exists(self, path: str) -> bool: 509 """Indicate whether this collection includes an item at `path` 510 511 This method returns `True` if `path` refers to a stream or file within 512 this collection, else `False`. 513 514 Arguments: 515 516 * path: str --- The path to check for existence within this collection 517 """ 518 return self.find(path) is not None
Indicate whether this collection includes an item at path
This method returns True if path refers to a stream or file within
this collection, else False.
Arguments:
- path: str — The path to check for existence within this collection
520 @must_be_writable 521 @synchronized 522 def remove(self, path: str, recursive: bool=False) -> None: 523 """Remove the file or stream at `path` 524 525 Arguments: 526 527 * path: str --- The path of the item to remove from the collection 528 529 * recursive: bool --- Controls the method's behavior if `path` refers 530 to a nonempty stream. If `False` (the default), this method raises 531 `OSError` with errno `ENOTEMPTY`. If `True`, this method removes all 532 items under the stream. 533 """ 534 if not path: 535 raise errors.ArgumentError("Parameter 'path' is empty.") 536 537 pathcomponents = path.split("/", 1) 538 item = self._items.get(pathcomponents[0]) 539 if item is None: 540 raise IOError(errno.ENOENT, "File not found", path) 541 if len(pathcomponents) == 1: 542 if isinstance(self._items[pathcomponents[0]], RichCollectionBase) and len(self._items[pathcomponents[0]]) > 0 and not recursive: 543 raise IOError(errno.ENOTEMPTY, "Directory not empty", path) 544 deleteditem = self._items[pathcomponents[0]] 545 del self._items[pathcomponents[0]] 546 self.set_committed(False) 547 self.notify(DEL, self, pathcomponents[0], deleteditem) 548 else: 549 item.remove(pathcomponents[1], recursive=recursive)
Remove the file or stream at path
Arguments:
path: str — The path of the item to remove from the collection
recursive: bool — Controls the method’s behavior if
pathrefers to a nonempty stream. IfFalse(the default), this method raisesOSErrorwith errnoENOTEMPTY. IfTrue, this method removes all items under the stream.
558 @must_be_writable 559 @synchronized 560 def add( 561 self, 562 source_obj: CollectionItem, 563 target_name: str, 564 overwrite: bool=False, 565 reparent: bool=False, 566 ) -> None: 567 """Copy or move a file or subcollection object to this collection 568 569 Arguments: 570 571 * source_obj: arvados.arvfile.ArvadosFile | Subcollection --- The file or subcollection 572 to add to this collection 573 574 * target_name: str --- The path inside this collection where 575 `source_obj` should be added. 576 577 * overwrite: bool --- Controls the behavior of this method when the 578 collection already contains an object at `target_name`. If `False` 579 (the default), this method will raise `FileExistsError`. If `True`, 580 the object at `target_name` will be replaced with `source_obj`. 581 582 * reparent: bool --- Controls whether this method copies or moves 583 `source_obj`. If `False` (the default), `source_obj` is copied into 584 this collection. If `True`, `source_obj` is moved into this 585 collection. 586 """ 587 if target_name in self and not overwrite: 588 raise IOError(errno.EEXIST, "File already exists", target_name) 589 590 modified_from = None 591 if target_name in self: 592 modified_from = self[target_name] 593 594 # Actually make the move or copy. 595 if reparent: 596 source_obj._reparent(self, target_name) 597 item = source_obj 598 else: 599 item = source_obj.clone(self, target_name) 600 601 self._items[target_name] = item 602 self.set_committed(False) 603 if not self._has_remote_blocks and source_obj.has_remote_blocks(): 604 self.set_has_remote_blocks(True) 605 606 if modified_from: 607 self.notify(MOD, self, target_name, (modified_from, item)) 608 else: 609 self.notify(ADD, self, target_name, item)
Copy or move a file or subcollection object to this collection
Arguments:
source_obj: arvados.arvfile.ArvadosFile | Subcollection — The file or subcollection to add to this collection
target_name: str — The path inside this collection where
source_objshould be added.overwrite: bool — Controls the behavior of this method when the collection already contains an object at
target_name. IfFalse(the default), this method will raiseFileExistsError. IfTrue, the object attarget_namewill be replaced withsource_obj.reparent: bool — Controls whether this method copies or moves
source_obj. IfFalse(the default),source_objis copied into this collection. IfTrue,source_objis moved into this collection.
651 @must_be_writable 652 @synchronized 653 def copy( 654 self, 655 source: Union[str, CollectionItem], 656 target_path: str, 657 source_collection: Optional['RichCollectionBase']=None, 658 overwrite: bool=False, 659 ) -> None: 660 """Copy a file or subcollection object to this collection 661 662 Arguments: 663 664 * source: str | arvados.arvfile.ArvadosFile | 665 arvados.collection.Subcollection --- The file or subcollection to 666 add to this collection. If `source` is a str, the object will be 667 found by looking up this path from `source_collection` (see 668 below). 669 670 * target_path: str --- The path inside this collection where the 671 source object should be added. 672 673 * source_collection: arvados.collection.Collection | None --- The 674 collection to find the source object from when `source` is a 675 path. Defaults to the current collection (`self`). 676 677 * overwrite: bool --- Controls the behavior of this method when the 678 collection already contains an object at `target_path`. If `False` 679 (the default), this method will raise `FileExistsError`. If `True`, 680 the object at `target_path` will be replaced with `source_obj`. 681 """ 682 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, True) 683 target_dir.add(source_obj, target_name, overwrite, False)
Copy a file or subcollection object to this collection
Arguments:
source: str | arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — The file or subcollection to add to this collection. If
sourceis a str, the object will be found by looking up this path fromsource_collection(see below).target_path: str — The path inside this collection where the source object should be added.
source_collection: arvados.collection.Collection | None — The collection to find the source object from when
sourceis a path. Defaults to the current collection (self).overwrite: bool — Controls the behavior of this method when the collection already contains an object at
target_path. IfFalse(the default), this method will raiseFileExistsError. IfTrue, the object attarget_pathwill be replaced withsource_obj.
685 @must_be_writable 686 @synchronized 687 def rename( 688 self, 689 source: Union[str, CollectionItem], 690 target_path: str, 691 source_collection: Optional['RichCollectionBase']=None, 692 overwrite: bool=False, 693 ) -> None: 694 """Move a file or subcollection object to this collection 695 696 Arguments: 697 698 * source: str | arvados.arvfile.ArvadosFile | 699 arvados.collection.Subcollection --- The file or subcollection to 700 add to this collection. If `source` is a str, the object will be 701 found by looking up this path from `source_collection` (see 702 below). 703 704 * target_path: str --- The path inside this collection where the 705 source object should be added. 706 707 * source_collection: arvados.collection.Collection | None --- The 708 collection to find the source object from when `source` is a 709 path. Defaults to the current collection (`self`). 710 711 * overwrite: bool --- Controls the behavior of this method when the 712 collection already contains an object at `target_path`. If `False` 713 (the default), this method will raise `FileExistsError`. If `True`, 714 the object at `target_path` will be replaced with `source_obj`. 715 """ 716 source_obj, target_dir, target_name = self._get_src_target(source, target_path, source_collection, False) 717 if not source_obj.writable(): 718 raise IOError(errno.EROFS, "Source collection is read only", source) 719 target_dir.add(source_obj, target_name, overwrite, True)
Move a file or subcollection object to this collection
Arguments:
source: str | arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — The file or subcollection to add to this collection. If
sourceis a str, the object will be found by looking up this path fromsource_collection(see below).target_path: str — The path inside this collection where the source object should be added.
source_collection: arvados.collection.Collection | None — The collection to find the source object from when
sourceis a path. Defaults to the current collection (self).overwrite: bool — Controls the behavior of this method when the collection already contains an object at
target_path. IfFalse(the default), this method will raiseFileExistsError. IfTrue, the object attarget_pathwill be replaced withsource_obj.
721 def portable_manifest_text(self, stream_name: str=".") -> str: 722 """Get the portable manifest text for this collection 723 724 The portable manifest text is normalized, and does not include access 725 tokens. This method does not flush outstanding blocks to Keep. 726 727 Arguments: 728 729 * stream_name: str --- The name to use for this collection's stream in 730 the generated manifest. Default `'.'`. 731 """ 732 return self._get_manifest_text(stream_name, True, True)
Get the portable manifest text for this collection
The portable manifest text is normalized, and does not include access tokens. This method does not flush outstanding blocks to Keep.
Arguments:
- stream_name: str — The name to use for this collection’s stream in
the generated manifest. Default
'.'.
734 @synchronized 735 def manifest_text( 736 self, 737 stream_name: str=".", 738 strip: bool=False, 739 normalize: bool=False, 740 only_committed: bool=False, 741 ) -> str: 742 """Get the manifest text for this collection 743 744 Arguments: 745 746 * stream_name: str --- The name to use for this collection's stream in 747 the generated manifest. Default `'.'`. 748 749 * strip: bool --- Controls whether or not the returned manifest text 750 includes access tokens. If `False` (the default), the manifest text 751 will include access tokens. If `True`, the manifest text will not 752 include access tokens. 753 754 * normalize: bool --- Controls whether or not the returned manifest 755 text is normalized. Default `False`. 756 757 * only_committed: bool --- Controls whether or not this method uploads 758 pending data to Keep before building and returning the manifest text. 759 If `False` (the default), this method will finish uploading all data 760 to Keep, then return the final manifest. If `True`, this method will 761 build and return a manifest that only refers to the data that has 762 finished uploading at the time this method was called. 763 """ 764 if not only_committed: 765 self._my_block_manager().commit_all() 766 return self._get_manifest_text(stream_name, strip, normalize, 767 only_committed=only_committed)
Get the manifest text for this collection
Arguments:
stream_name: str — The name to use for this collection’s stream in the generated manifest. Default
'.'.strip: bool — Controls whether or not the returned manifest text includes access tokens. If
False(the default), the manifest text will include access tokens. IfTrue, the manifest text will not include access tokens.normalize: bool — Controls whether or not the returned manifest text is normalized. Default
False.only_committed: bool — Controls whether or not this method uploads pending data to Keep before building and returning the manifest text. If
False(the default), this method will finish uploading all data to Keep, then return the final manifest. IfTrue, this method will build and return a manifest that only refers to the data that has finished uploading at the time this method was called.
845 @synchronized 846 def diff( 847 self, 848 end_collection: 'RichCollectionBase', 849 prefix: str=".", 850 holding_collection: Optional['Collection']=None, 851 ) -> ChangeList: 852 """Build a list of differences between this collection and another 853 854 Arguments: 855 856 * end_collection: arvados.collection.RichCollectionBase --- A 857 collection object with the desired end state. The returned diff 858 list will describe how to go from the current collection object 859 `self` to `end_collection`. 860 861 * prefix: str --- The name to use for this collection's stream in 862 the diff list. Default `'.'`. 863 864 * holding_collection: arvados.collection.Collection | None --- A 865 collection object used to hold objects for the returned diff 866 list. By default, a new empty collection is created. 867 """ 868 changes = [] 869 if holding_collection is None: 870 holding_collection = Collection(api_client=self._my_api(), keep_client=self._my_keep()) 871 for k in self: 872 if k not in end_collection: 873 changes.append((DEL, os.path.join(prefix, k), self[k].clone(holding_collection, ""))) 874 for k in end_collection: 875 if k in self: 876 if isinstance(end_collection[k], Subcollection) and isinstance(self[k], Subcollection): 877 changes.extend(self[k].diff(end_collection[k], os.path.join(prefix, k), holding_collection)) 878 elif end_collection[k] != self[k]: 879 changes.append((MOD, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 880 else: 881 changes.append((TOK, os.path.join(prefix, k), self[k].clone(holding_collection, ""), end_collection[k].clone(holding_collection, ""))) 882 else: 883 changes.append((ADD, os.path.join(prefix, k), end_collection[k].clone(holding_collection, ""))) 884 return changes
Build a list of differences between this collection and another
Arguments:
end_collection: arvados.collection.RichCollectionBase — A collection object with the desired end state. The returned diff list will describe how to go from the current collection object
selftoend_collection.prefix: str — The name to use for this collection’s stream in the diff list. Default
'.'.holding_collection: arvados.collection.Collection | None — A collection object used to hold objects for the returned diff list. By default, a new empty collection is created.
886 @must_be_writable 887 @synchronized 888 def apply(self, changes: ChangeList) -> None: 889 """Apply a list of changes from to this collection 890 891 This method takes a list of changes generated by 892 `RichCollectionBase.diff` and applies it to this 893 collection. Afterward, the state of this collection object will 894 match the state of `end_collection` passed to `diff`. If a change 895 conflicts with a local change, it will be saved to an alternate path 896 indicating the conflict. 897 898 Arguments: 899 900 * changes: arvados.collection.ChangeList --- The list of differences 901 generated by `RichCollectionBase.diff`. 902 """ 903 if changes: 904 self.set_committed(False) 905 for change in changes: 906 event_type = change[0] 907 path = change[1] 908 initial = change[2] 909 local = self.find(path) 910 conflictpath = "%s~%s~conflict~" % (path, time.strftime("%Y%m%d-%H%M%S", 911 time.gmtime())) 912 if event_type == ADD: 913 if local is None: 914 # No local file at path, safe to copy over new file 915 self.copy(initial, path) 916 elif local is not None and local != initial: 917 # There is already local file and it is different: 918 # save change to conflict file. 919 self.copy(initial, conflictpath) 920 elif event_type == MOD or event_type == TOK: 921 final = change[3] 922 if local == initial: 923 # Local matches the "initial" item so it has not 924 # changed locally and is safe to update. 925 if isinstance(local, ArvadosFile) and isinstance(final, ArvadosFile): 926 # Replace contents of local file with new contents 927 local.replace_contents(final) 928 else: 929 # Overwrite path with new item; this can happen if 930 # path was a file and is now a collection or vice versa 931 self.copy(final, path, overwrite=True) 932 elif event_type == MOD: 933 # Local doesn't match the "start" value or local 934 # is missing (presumably deleted) so save change 935 # to conflict file. Don't do this for TOK events 936 # which means the file didn't change but only had 937 # tokens updated. 938 self.copy(final, conflictpath) 939 elif event_type == DEL: 940 if local == initial: 941 # Local item matches "initial" value, so it is safe to remove. 942 self.remove(path, recursive=True) 943 # else, the file is modified or already removed, in either 944 # case we don't want to try to remove it.
Apply a list of changes from to this collection
This method takes a list of changes generated by
RichCollectionBase.diff and applies it to this
collection. Afterward, the state of this collection object will
match the state of end_collection passed to diff. If a change
conflicts with a local change, it will be saved to an alternate path
indicating the conflict.
Arguments:
- changes: arvados.collection.ChangeList — The list of differences
generated by
RichCollectionBase.diff.
946 def portable_data_hash(self) -> str: 947 """Get the portable data hash for this collection's manifest""" 948 if self._manifest_locator and self.committed(): 949 # If the collection is already saved on the API server, and it's committed 950 # then return API server's PDH response. 951 return self._portable_data_hash 952 else: 953 stripped = self.portable_manifest_text().encode() 954 return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped))
Get the portable data hash for this collection’s manifest
956 @synchronized 957 def subscribe(self, callback: ChangeCallback) -> None: 958 """Set a notify callback for changes to this collection 959 960 Arguments: 961 962 * callback: arvados.collection.ChangeCallback --- The callable to 963 call each time the collection is changed. 964 """ 965 if self._callback is None: 966 self._callback = callback 967 else: 968 raise errors.ArgumentError("A callback is already set on this collection.")
Set a notify callback for changes to this collection
Arguments:
- callback: arvados.collection.ChangeCallback — The callable to call each time the collection is changed.
970 @synchronized 971 def unsubscribe(self) -> None: 972 """Remove any notify callback set for changes to this collection""" 973 if self._callback is not None: 974 self._callback = None
Remove any notify callback set for changes to this collection
976 @synchronized 977 def notify( 978 self, 979 event: ChangeType, 980 collection: 'RichCollectionBase', 981 name: str, 982 item: CollectionItem, 983 ) -> None: 984 """Notify any subscribed callback about a change to this collection 985 986 .. ATTENTION:: Internal 987 This method is only meant to be used by other Collection methods. 988 989 If a callback has been registered with `RichCollectionBase.subscribe`, 990 it will be called with information about a change to this collection. 991 Then this notification will be propagated to this collection's root. 992 993 Arguments: 994 995 * event: Literal[ADD, DEL, MOD, TOK] --- The type of modification to 996 the collection. 997 998 * collection: arvados.collection.RichCollectionBase --- The 999 collection that was modified. 1000 1001 * name: str --- The name of the file or stream within `collection` that 1002 was modified. 1003 1004 * item: arvados.arvfile.ArvadosFile | 1005 arvados.collection.Subcollection --- For ADD events, the new 1006 contents at `name` within `collection`; for DEL events, the 1007 item that was removed. For MOD and TOK events, a 2-tuple of 1008 the previous item and the new item (may be the same object 1009 or different, depending on whether the action involved it 1010 being modified in place or replaced). 1011 1012 """ 1013 if self._callback: 1014 self._callback(event, collection, name, item) 1015 self.root_collection().notify(event, collection, name, item)
Notify any subscribed callback about a change to this collection
If a callback has been registered with RichCollectionBase.subscribe,
it will be called with information about a change to this collection.
Then this notification will be propagated to this collection’s root.
Arguments:
event: Literal[ADD, DEL, MOD, TOK] — The type of modification to the collection.
collection: arvados.collection.RichCollectionBase — The collection that was modified.
name: str — The name of the file or stream within
collectionthat was modified.item: arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — For ADD events, the new contents at
namewithincollection; for DEL events, the item that was removed. For MOD and TOK events, a 2-tuple of the previous item and the new item (may be the same object or different, depending on whether the action involved it being modified in place or replaced).
1037 @synchronized 1038 def flush(self) -> None: 1039 """Upload any pending data to Keep""" 1040 for e in self.values(): 1041 e.flush()
Upload any pending data to Keep
Inherited Members
1044class Collection(RichCollectionBase): 1045 """Read and manipulate an Arvados collection 1046 1047 This class provides a high-level interface to create, read, and update 1048 Arvados collections and their contents. Refer to the Arvados Python SDK 1049 cookbook for [an introduction to using the Collection class][cookbook]. 1050 1051 [cookbook]: https://doc.arvados.org/sdk/python/cookbook.html#working-with-collections 1052 """ 1053 1054 def __init__(self, manifest_locator_or_text: Optional[str]=None, 1055 api_client: Optional['arvados.api_resources.ArvadosAPIClient']=None, 1056 keep_client: Optional['arvados.keep.KeepClient']=None, 1057 num_retries: int=10, 1058 parent: Optional['Collection']=None, 1059 apiconfig: Optional[Mapping[str, str]]=None, 1060 block_manager: Optional['arvados.arvfile._BlockManager']=None, 1061 replication_desired: Optional[int]=None, 1062 storage_classes_desired: Optional[List[str]]=None, 1063 put_threads: Optional[int]=None): 1064 """Initialize a Collection object 1065 1066 Arguments: 1067 1068 * manifest_locator_or_text: str | None --- This string can contain a 1069 collection manifest text, portable data hash, or UUID. When given a 1070 portable data hash or UUID, this instance will load a collection 1071 record from the API server. Otherwise, this instance will represent a 1072 new collection without an API server record. The default value `None` 1073 instantiates a new collection with an empty manifest. 1074 1075 * api_client: arvados.api_resources.ArvadosAPIClient | None --- The 1076 Arvados API client object this instance uses to make requests. If 1077 none is given, this instance creates its own client using the 1078 settings from `apiconfig` (see below). If your client instantiates 1079 many Collection objects, you can help limit memory utilization by 1080 calling `arvados.api.api` to construct an 1081 `arvados.api.ThreadSafeAPIClient`, and use that as the `api_client` 1082 for every Collection. 1083 1084 * keep_client: arvados.keep.KeepClient | None --- The Keep client 1085 object this instance uses to make requests. If none is given, this 1086 instance creates its own client using its `api_client`. 1087 1088 * num_retries: int --- The number of times that client requests are 1089 retried. Default 10. 1090 1091 * parent: arvados.collection.Collection | None --- The parent Collection 1092 object of this instance, if any. This argument is primarily used by 1093 other Collection methods; user client code shouldn't need to use it. 1094 1095 * apiconfig: Mapping[str, str] | None --- A mapping with entries for 1096 `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally 1097 `ARVADOS_API_HOST_INSECURE`. When no `api_client` is provided, the 1098 Collection object constructs one from these settings. If no 1099 mapping is provided, calls `arvados.config.settings` to get these 1100 parameters from user configuration. 1101 1102 * block_manager: arvados.arvfile._BlockManager | None --- The 1103 _BlockManager object used by this instance to coordinate reading 1104 and writing Keep data blocks. If none is given, this instance 1105 constructs its own. This argument is primarily used by other 1106 Collection methods; user client code shouldn't need to use it. 1107 1108 * replication_desired: int | None --- This controls both the value of 1109 the `replication_desired` field on API collection records saved by 1110 this class, as well as the number of Keep services that the object 1111 writes new data blocks to. If none is given, uses the default value 1112 configured for the cluster. 1113 1114 * storage_classes_desired: list[str] | None --- This controls both 1115 the value of the `storage_classes_desired` field on API collection 1116 records saved by this class, as well as selecting which specific 1117 Keep services the object writes new data blocks to. If none is 1118 given, defaults to an empty list. 1119 1120 * put_threads: int | None --- The number of threads to run 1121 simultaneously to upload data blocks to Keep. This value is used when 1122 building a new `block_manager`. It is unused when a `block_manager` 1123 is provided. 1124 """ 1125 1126 if storage_classes_desired and type(storage_classes_desired) is not list: 1127 raise errors.ArgumentError("storage_classes_desired must be list type.") 1128 1129 super(Collection, self).__init__(parent) 1130 self._api_client = api_client 1131 self._keep_client = keep_client 1132 1133 # Use the keep client from ThreadSafeAPIClient 1134 if self._keep_client is None and isinstance(self._api_client, ThreadSafeAPIClient): 1135 self._keep_client = self._api_client.keep 1136 1137 self._block_manager = block_manager 1138 self.replication_desired = replication_desired 1139 self._storage_classes_desired = storage_classes_desired 1140 self.put_threads = put_threads 1141 1142 if apiconfig: 1143 self._config = apiconfig 1144 else: 1145 self._config = config.settings() 1146 1147 self.num_retries = num_retries 1148 self._manifest_locator = None 1149 self._manifest_text = None 1150 self._portable_data_hash = None 1151 self._api_response = None 1152 self._token_refresh_timestamp = 0 1153 1154 self.lock = threading.RLock() 1155 self.events = None 1156 1157 if manifest_locator_or_text: 1158 if re.match(arvados.util.keep_locator_pattern, manifest_locator_or_text): 1159 self._manifest_locator = manifest_locator_or_text 1160 elif re.match(arvados.util.collection_uuid_pattern, manifest_locator_or_text): 1161 self._manifest_locator = manifest_locator_or_text 1162 if not self._has_local_collection_uuid(): 1163 self._has_remote_blocks = True 1164 elif re.match(arvados.util.manifest_pattern, manifest_locator_or_text): 1165 self._manifest_text = manifest_locator_or_text 1166 if '+R' in self._manifest_text: 1167 self._has_remote_blocks = True 1168 else: 1169 raise errors.ArgumentError( 1170 "Argument to CollectionReader is not a manifest or a collection UUID") 1171 1172 try: 1173 self._populate() 1174 except errors.SyntaxError as e: 1175 raise errors.ArgumentError("Error processing manifest text: %s", str(e)) from None 1176 1177 def storage_classes_desired(self) -> List[str]: 1178 """Get this collection's `storage_classes_desired` value""" 1179 return self._storage_classes_desired or [] 1180 1181 def root_collection(self) -> 'Collection': 1182 return self 1183 1184 def get_properties(self) -> Properties: 1185 """Get this collection's properties 1186 1187 This method always returns a dict. If this collection object does not 1188 have an associated API record, or that record does not have any 1189 properties set, this method returns an empty dict. 1190 """ 1191 if self._api_response and self._api_response["properties"]: 1192 return self._api_response["properties"] 1193 else: 1194 return {} 1195 1196 def get_trash_at(self) -> Optional[datetime.datetime]: 1197 """Get this collection's `trash_at` field 1198 1199 This method parses the `trash_at` field of the collection's API 1200 record and returns a datetime from it. If that field is not set, or 1201 this collection object does not have an associated API record, 1202 returns None. 1203 """ 1204 if self._api_response and self._api_response["trash_at"]: 1205 try: 1206 return ciso8601.parse_datetime(self._api_response["trash_at"]) 1207 except ValueError: 1208 return None 1209 else: 1210 return None 1211 1212 def stream_name(self) -> str: 1213 return "." 1214 1215 def writable(self) -> bool: 1216 return True 1217 1218 @synchronized 1219 @retry_method 1220 def update( 1221 self, 1222 other: Optional['Collection']=None, 1223 num_retries: Optional[int]=None, 1224 ) -> None: 1225 """Merge another collection's contents into this one 1226 1227 This method compares the manifest of this collection instance with 1228 another, then updates this instance's manifest with changes from the 1229 other, renaming files to flag conflicts where necessary. 1230 1231 When called without any arguments, this method reloads the collection's 1232 API record, and updates this instance with any changes that have 1233 appeared server-side. If this instance does not have a corresponding 1234 API record, this method raises `arvados.errors.ArgumentError`. 1235 1236 Arguments: 1237 1238 * other: arvados.collection.Collection | None --- The collection 1239 whose contents should be merged into this instance. When not 1240 provided, this method reloads this collection's API record and 1241 constructs a Collection object from it. If this instance does not 1242 have a corresponding API record, this method raises 1243 `arvados.errors.ArgumentError`. 1244 1245 * num_retries: int | None --- The number of times to retry reloading 1246 the collection's API record from the API server. If not specified, 1247 uses the `num_retries` provided when this instance was constructed. 1248 """ 1249 1250 token_refresh_period = 60*60 1251 time_since_last_token_refresh = (time.time() - self._token_refresh_timestamp) 1252 upstream_response = None 1253 1254 if other is None: 1255 if self._manifest_locator is None: 1256 raise errors.ArgumentError("`other` is None but collection does not have a manifest_locator uuid") 1257 1258 if re.match(arvados.util.portable_data_hash_pattern, self._manifest_locator) and time_since_last_token_refresh < token_refresh_period: 1259 return 1260 1261 upstream_response = self._my_api().collections().get(uuid=self._manifest_locator).execute(num_retries=num_retries) 1262 other = CollectionReader(upstream_response["manifest_text"], self._my_api()) 1263 1264 if self.committed(): 1265 # 1st case, no local changes, content is the same 1266 if self.portable_data_hash() == other.portable_data_hash() and time_since_last_token_refresh < token_refresh_period: 1267 # No difference in content. Remember the API record 1268 # (metadata such as name or properties may have changed) 1269 # but don't update the token refresh timestamp. 1270 if upstream_response is not None: 1271 self._remember_api_response(upstream_response) 1272 return 1273 1274 # 2nd case, no local changes, but either upstream changed 1275 # or we want to refresh tokens. 1276 1277 self.apply(self.diff(other)) 1278 if upstream_response is not None: 1279 self._remember_api_response(upstream_response) 1280 self._update_token_timestamp() 1281 self.set_committed(True) 1282 return 1283 1284 # 3rd case, upstream changed, but we also have uncommitted 1285 # changes that we want to incorporate so they don't get lost. 1286 1287 # _manifest_text stores the text from last time we received a 1288 # record from the API server. This is the state of the 1289 # collection before our uncommitted changes. 1290 baseline = Collection(self._manifest_text, self._my_api()) 1291 1292 # Get the set of changes between our baseline and the other 1293 # collection and apply them to self. 1294 # 1295 # If a file was modified in both 'self' and 'other', the 1296 # 'apply' method keeps the contents of 'self' and creates a 1297 # conflict file with the contents of 'other'. 1298 self.apply(baseline.diff(other)) 1299 1300 # Remember the new baseline, changes to a file 1301 if upstream_response is not None: 1302 self._remember_api_response(upstream_response) 1303 1304 1305 @synchronized 1306 def _my_api(self): 1307 if self._api_client is None: 1308 self._api_client = ThreadSafeAPIClient(self._config, version='v1') 1309 if self._keep_client is None: 1310 self._keep_client = self._api_client.keep 1311 return self._api_client 1312 1313 @synchronized 1314 def _my_keep(self): 1315 if self._keep_client is None: 1316 if self._api_client is None: 1317 self._my_api() 1318 else: 1319 self._keep_client = KeepClient(api_client=self._api_client) 1320 return self._keep_client 1321 1322 @synchronized 1323 def _my_block_manager(self): 1324 if self._block_manager is None: 1325 copies = (self.replication_desired or 1326 self._my_api()._rootDesc.get('defaultCollectionReplication', 1327 2)) 1328 self._block_manager = _BlockManager(self._my_keep(), 1329 copies=copies, 1330 put_threads=self.put_threads, 1331 num_retries=self.num_retries, 1332 storage_classes_func=self.storage_classes_desired) 1333 return self._block_manager 1334 1335 def _remember_api_response(self, response): 1336 self._api_response = response 1337 self._manifest_text = self._api_response['manifest_text'] 1338 self._portable_data_hash = self._api_response['portable_data_hash'] 1339 1340 def _update_token_timestamp(self): 1341 self._token_refresh_timestamp = time.time() 1342 1343 def _populate_from_api_server(self): 1344 # As in KeepClient itself, we must wait until the last 1345 # possible moment to instantiate an API client, in order to 1346 # avoid tripping up clients that don't have access to an API 1347 # server. If we do build one, make sure our Keep client uses 1348 # it. If instantiation fails, we'll fall back to the except 1349 # clause, just like any other Collection lookup 1350 # failure. Return an exception, or None if successful. 1351 self._remember_api_response(self._my_api().collections().get( 1352 uuid=self._manifest_locator).execute( 1353 num_retries=self.num_retries)) 1354 1355 # If not overriden via kwargs, we should try to load the 1356 # replication_desired and storage_classes_desired from the API server 1357 if self.replication_desired is None: 1358 self.replication_desired = self._api_response.get('replication_desired', None) 1359 if self._storage_classes_desired is None: 1360 self._storage_classes_desired = self._api_response.get('storage_classes_desired', None) 1361 1362 def _populate(self): 1363 if self._manifest_text is None: 1364 if self._manifest_locator is None: 1365 return 1366 else: 1367 self._populate_from_api_server() 1368 self._baseline_manifest = self._manifest_text 1369 self._import_manifest(self._manifest_text) 1370 1371 def _has_collection_uuid(self): 1372 return self._manifest_locator is not None and re.match(arvados.util.collection_uuid_pattern, self._manifest_locator) 1373 1374 def _has_local_collection_uuid(self): 1375 return self._has_collection_uuid and \ 1376 self._my_api()._rootDesc['uuidPrefix'] == self._manifest_locator.split('-')[0] 1377 1378 def __enter__(self): 1379 return self 1380 1381 def __exit__(self, exc_type, exc_value, traceback): 1382 """Exit a context with this collection instance 1383 1384 If no exception was raised inside the context block, and this 1385 collection is writable and has a corresponding API record, that 1386 record will be updated to match the state of this instance at the end 1387 of the block. 1388 """ 1389 if exc_type is None: 1390 if self.writable() and self._has_collection_uuid(): 1391 self.save() 1392 self.stop_threads() 1393 1394 def stop_threads(self) -> None: 1395 """Stop background Keep upload/download threads""" 1396 if self._block_manager is not None: 1397 self._block_manager.stop_threads() 1398 1399 @synchronized 1400 def manifest_locator(self) -> Optional[str]: 1401 """Get this collection's manifest locator, if any 1402 1403 * If this collection instance is associated with an API record with a 1404 UUID, return that. 1405 * Otherwise, if this collection instance was loaded from an API record 1406 by portable data hash, return that. 1407 * Otherwise, return `None`. 1408 """ 1409 return self._manifest_locator 1410 1411 @synchronized 1412 def clone( 1413 self, 1414 new_parent: Optional['Collection']=None, 1415 new_name: Optional[str]=None, 1416 readonly: bool=False, 1417 new_config: Optional[Mapping[str, str]]=None, 1418 ) -> 'Collection': 1419 """Create a Collection object with the same contents as this instance 1420 1421 This method creates a new Collection object with contents that match 1422 this instance's. The new collection will not be associated with any API 1423 record. 1424 1425 Arguments: 1426 1427 * new_parent: arvados.collection.Collection | None --- This value is 1428 passed to the new Collection's constructor as the `parent` 1429 argument. 1430 1431 * new_name: str | None --- This value is unused. 1432 1433 * readonly: bool --- If this value is true, this method constructs and 1434 returns a `CollectionReader`. Otherwise, it returns a mutable 1435 `Collection`. Default `False`. 1436 1437 * new_config: Mapping[str, str] | None --- This value is passed to the 1438 new Collection's constructor as `apiconfig`. If no value is provided, 1439 defaults to the configuration passed to this instance's constructor. 1440 """ 1441 if new_config is None: 1442 new_config = self._config 1443 if readonly: 1444 newcollection = CollectionReader(parent=new_parent, apiconfig=new_config) 1445 else: 1446 newcollection = Collection(parent=new_parent, apiconfig=new_config) 1447 1448 newcollection._clonefrom(self) 1449 return newcollection 1450 1451 @synchronized 1452 def api_response(self) -> Optional[Dict[str, Any]]: 1453 """Get this instance's associated API record 1454 1455 If this Collection instance has an associated API record, return it. 1456 Otherwise, return `None`. 1457 """ 1458 return self._api_response 1459 1460 def find_or_create( 1461 self, 1462 path: str, 1463 create_type: CreateType, 1464 ) -> CollectionItem: 1465 if path == ".": 1466 return self 1467 else: 1468 return super(Collection, self).find_or_create(path[2:] if path.startswith("./") else path, create_type) 1469 1470 def find(self, path: str) -> CollectionItem | None: 1471 if path == ".": 1472 return self 1473 else: 1474 return super(Collection, self).find(path[2:] if path.startswith("./") else path) 1475 1476 def remove(self, path: str, recursive: bool=False) -> None: 1477 if path == ".": 1478 raise errors.ArgumentError("Cannot remove '.'") 1479 else: 1480 return super(Collection, self).remove(path[2:] if path.startswith("./") else path, recursive) 1481 1482 @must_be_writable 1483 @synchronized 1484 @retry_method 1485 def save( 1486 self, 1487 properties: Optional[Properties]=None, 1488 storage_classes: Optional[StorageClasses]=None, 1489 trash_at: Optional[datetime.datetime]=None, 1490 merge: bool=True, 1491 num_retries: Optional[int]=None, 1492 preserve_version: bool=False, 1493 ) -> str: 1494 """Save collection to an existing API record 1495 1496 This method updates the instance's corresponding API record to match 1497 the instance's state. If this instance does not have a corresponding API 1498 record yet, raises `AssertionError`. (To create a new API record, use 1499 `Collection.save_new`.) This method returns the saved collection 1500 manifest. 1501 1502 Arguments: 1503 1504 * properties: dict[str, Any] | None --- If provided, the API record will 1505 be updated with these properties. Note this will completely replace 1506 any existing properties. 1507 1508 * storage_classes: list[str] | None --- If provided, the API record will 1509 be updated with this value in the `storage_classes_desired` field. 1510 This value will also be saved on the instance and used for any 1511 changes that follow. 1512 1513 * trash_at: datetime.datetime | None --- If provided, the API record 1514 will be updated with this value in the `trash_at` field. 1515 1516 * merge: bool --- If `True` (the default), this method will first 1517 reload this collection's API record, and merge any new contents into 1518 this instance before saving changes. See `Collection.update` for 1519 details. 1520 1521 * num_retries: int | None --- The number of times to retry reloading 1522 the collection's API record from the API server. If not specified, 1523 uses the `num_retries` provided when this instance was constructed. 1524 1525 * preserve_version: bool --- This value will be passed to directly 1526 to the underlying API call. If `True`, the Arvados API will 1527 preserve the versions of this collection both immediately before 1528 and after the update. If `True` when the API server is not 1529 configured with collection versioning, this method raises 1530 `arvados.errors.ArgumentError`. 1531 """ 1532 if properties and type(properties) is not dict: 1533 raise errors.ArgumentError("properties must be dictionary type.") 1534 1535 if storage_classes and type(storage_classes) is not list: 1536 raise errors.ArgumentError("storage_classes must be list type.") 1537 if storage_classes: 1538 self._storage_classes_desired = storage_classes 1539 1540 if trash_at and type(trash_at) is not datetime.datetime: 1541 raise errors.ArgumentError("trash_at must be datetime type.") 1542 1543 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1544 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1545 1546 body={} 1547 if properties: 1548 body["properties"] = properties 1549 if self.storage_classes_desired(): 1550 body["storage_classes_desired"] = self.storage_classes_desired() 1551 if trash_at: 1552 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1553 body["trash_at"] = t 1554 if preserve_version: 1555 body["preserve_version"] = preserve_version 1556 1557 if not self.committed(): 1558 if self._has_remote_blocks: 1559 # Copy any remote blocks to the local cluster. 1560 self._copy_remote_blocks(remote_blocks={}) 1561 self._has_remote_blocks = False 1562 if not self._has_collection_uuid(): 1563 raise AssertionError("Collection manifest_locator is not a collection uuid. Use save_new() for new collections.") 1564 elif not self._has_local_collection_uuid(): 1565 raise AssertionError("Collection manifest_locator is from a remote cluster. Use save_new() to save it on the local cluster.") 1566 1567 self._my_block_manager().commit_all() 1568 1569 if merge: 1570 self.update() 1571 1572 text = self.manifest_text(strip=False) 1573 body['manifest_text'] = text 1574 1575 self._remember_api_response(self._my_api().collections().update( 1576 uuid=self._manifest_locator, 1577 body=body 1578 ).execute(num_retries=num_retries)) 1579 self.set_committed(True) 1580 elif body: 1581 self._remember_api_response(self._my_api().collections().update( 1582 uuid=self._manifest_locator, 1583 body=body 1584 ).execute(num_retries=num_retries)) 1585 1586 return self._manifest_text 1587 1588 1589 @must_be_writable 1590 @synchronized 1591 @retry_method 1592 def save_new( 1593 self, 1594 name: Optional[str]=None, 1595 create_collection_record: bool=True, 1596 owner_uuid: Optional[str]=None, 1597 properties: Optional[Properties]=None, 1598 storage_classes: Optional[StorageClasses]=None, 1599 trash_at: Optional[datetime.datetime]=None, 1600 ensure_unique_name: bool=False, 1601 num_retries: Optional[int]=None, 1602 preserve_version: bool=False, 1603 ): 1604 """Save collection to a new API record 1605 1606 This method finishes uploading new data blocks and (optionally) 1607 creates a new API collection record with the provided data. If a new 1608 record is created, this instance becomes associated with that record 1609 for future updates like `save()`. This method returns the saved 1610 collection manifest. 1611 1612 Arguments: 1613 1614 * name: str | None --- The `name` field to use on the new collection 1615 record. If not specified, a generic default name is generated. 1616 1617 * create_collection_record: bool --- If `True` (the default), creates a 1618 collection record on the API server. If `False`, the method finishes 1619 all data uploads and only returns the resulting collection manifest 1620 without sending it to the API server. 1621 1622 * owner_uuid: str | None --- The `owner_uuid` field to use on the 1623 new collection record. 1624 1625 * properties: dict[str, Any] | None --- The `properties` field to use on 1626 the new collection record. 1627 1628 * storage_classes: list[str] | None --- The 1629 `storage_classes_desired` field to use on the new collection record. 1630 1631 * trash_at: datetime.datetime | None --- The `trash_at` field to use 1632 on the new collection record. 1633 1634 * ensure_unique_name: bool --- This value is passed directly to the 1635 Arvados API when creating the collection record. If `True`, the API 1636 server may modify the submitted `name` to ensure the collection's 1637 `name`+`owner_uuid` combination is unique. If `False` (the default), 1638 if a collection already exists with this same `name`+`owner_uuid` 1639 combination, creating a collection record will raise a validation 1640 error. 1641 1642 * num_retries: int | None --- The number of times to retry reloading 1643 the collection's API record from the API server. If not specified, 1644 uses the `num_retries` provided when this instance was constructed. 1645 1646 * preserve_version: bool --- This value will be passed to directly 1647 to the underlying API call. If `True`, the Arvados API will 1648 preserve the versions of this collection both immediately before 1649 and after the update. If `True` when the API server is not 1650 configured with collection versioning, this method raises 1651 `arvados.errors.ArgumentError`. 1652 """ 1653 if properties and type(properties) is not dict: 1654 raise errors.ArgumentError("properties must be dictionary type.") 1655 1656 if storage_classes and type(storage_classes) is not list: 1657 raise errors.ArgumentError("storage_classes must be list type.") 1658 1659 if trash_at and type(trash_at) is not datetime.datetime: 1660 raise errors.ArgumentError("trash_at must be datetime type.") 1661 1662 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1663 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1664 1665 if self._has_remote_blocks: 1666 # Copy any remote blocks to the local cluster. 1667 self._copy_remote_blocks(remote_blocks={}) 1668 self._has_remote_blocks = False 1669 1670 if storage_classes: 1671 self._storage_classes_desired = storage_classes 1672 1673 self._my_block_manager().commit_all() 1674 text = self.manifest_text(strip=False) 1675 1676 if create_collection_record: 1677 if name is None: 1678 name = "New collection" 1679 ensure_unique_name = True 1680 1681 body = {"manifest_text": text, 1682 "name": name, 1683 "replication_desired": self.replication_desired} 1684 if owner_uuid: 1685 body["owner_uuid"] = owner_uuid 1686 if properties: 1687 body["properties"] = properties 1688 if self.storage_classes_desired(): 1689 body["storage_classes_desired"] = self.storage_classes_desired() 1690 if trash_at: 1691 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1692 body["trash_at"] = t 1693 if preserve_version: 1694 body["preserve_version"] = preserve_version 1695 1696 self._remember_api_response(self._my_api().collections().create(ensure_unique_name=ensure_unique_name, body=body).execute(num_retries=num_retries)) 1697 self._manifest_locator = self._api_response["uuid"] 1698 self.set_committed(True) 1699 1700 return text 1701 1702 _token_re = re.compile(r'(\S+)(\s+|$)') 1703 _block_re = re.compile(r'[0-9a-f]{32}\+(\d+)(\+\S+)*') 1704 _segment_re = re.compile(r'(\d+):(\d+):(\S+)') 1705 1706 def _unescape_manifest_path(self, path): 1707 return re.sub(r'\\([0-3][0-7][0-7])', lambda m: chr(int(m.group(1), 8)), path) 1708 1709 @synchronized 1710 def _import_manifest(self, manifest_text): 1711 """Import a manifest into a `Collection`. 1712 1713 :manifest_text: 1714 The manifest text to import from. 1715 1716 """ 1717 if len(self) > 0: 1718 raise ArgumentError("Can only import manifest into an empty collection") 1719 1720 STREAM_NAME = 0 1721 BLOCKS = 1 1722 SEGMENTS = 2 1723 1724 stream_name = None 1725 state = STREAM_NAME 1726 1727 for token_and_separator in self._token_re.finditer(manifest_text): 1728 tok = token_and_separator.group(1) 1729 sep = token_and_separator.group(2) 1730 1731 if state == STREAM_NAME: 1732 # starting a new stream 1733 stream_name = self._unescape_manifest_path(tok) 1734 blocks = [] 1735 segments = [] 1736 streamoffset = 0 1737 state = BLOCKS 1738 self.find_or_create(stream_name, COLLECTION) 1739 continue 1740 1741 if state == BLOCKS: 1742 block_locator = self._block_re.match(tok) 1743 if block_locator: 1744 blocksize = int(block_locator.group(1)) 1745 blocks.append(streams.Range(tok, streamoffset, blocksize, 0)) 1746 streamoffset += blocksize 1747 else: 1748 state = SEGMENTS 1749 1750 if state == SEGMENTS: 1751 file_segment = self._segment_re.match(tok) 1752 if file_segment: 1753 pos = int(file_segment.group(1)) 1754 size = int(file_segment.group(2)) 1755 name = self._unescape_manifest_path(file_segment.group(3)) 1756 if name.split('/')[-1] == '.': 1757 # placeholder for persisting an empty directory, not a real file 1758 if len(name) > 2: 1759 self.find_or_create(os.path.join(stream_name, name[:-2]), COLLECTION) 1760 else: 1761 filepath = os.path.join(stream_name, name) 1762 try: 1763 afile = self.find_or_create(filepath, FILE) 1764 except IOError as e: 1765 if e.errno == errno.ENOTDIR: 1766 raise errors.SyntaxError("Dir part of %s conflicts with file of the same name.", filepath) from None 1767 else: 1768 raise e from None 1769 if isinstance(afile, ArvadosFile): 1770 afile.add_segment(blocks, pos, size) 1771 else: 1772 raise errors.SyntaxError("File %s conflicts with stream of the same name.", filepath) 1773 else: 1774 # error! 1775 raise errors.SyntaxError("Invalid manifest format, expected file segment but did not match format: '%s'" % tok) 1776 1777 if sep == "\n": 1778 stream_name = None 1779 state = STREAM_NAME 1780 1781 self._update_token_timestamp() 1782 self.set_committed(True) 1783 1784 @synchronized 1785 def notify( 1786 self, 1787 event: ChangeType, 1788 collection: 'RichCollectionBase', 1789 name: str, 1790 item: CollectionItem, 1791 ) -> None: 1792 if self._callback: 1793 self._callback(event, collection, name, item)
Read and manipulate an Arvados collection
This class provides a high-level interface to create, read, and update Arvados collections and their contents. Refer to the Arvados Python SDK cookbook for an introduction to using the Collection class.
1054 def __init__(self, manifest_locator_or_text: Optional[str]=None, 1055 api_client: Optional['arvados.api_resources.ArvadosAPIClient']=None, 1056 keep_client: Optional['arvados.keep.KeepClient']=None, 1057 num_retries: int=10, 1058 parent: Optional['Collection']=None, 1059 apiconfig: Optional[Mapping[str, str]]=None, 1060 block_manager: Optional['arvados.arvfile._BlockManager']=None, 1061 replication_desired: Optional[int]=None, 1062 storage_classes_desired: Optional[List[str]]=None, 1063 put_threads: Optional[int]=None): 1064 """Initialize a Collection object 1065 1066 Arguments: 1067 1068 * manifest_locator_or_text: str | None --- This string can contain a 1069 collection manifest text, portable data hash, or UUID. When given a 1070 portable data hash or UUID, this instance will load a collection 1071 record from the API server. Otherwise, this instance will represent a 1072 new collection without an API server record. The default value `None` 1073 instantiates a new collection with an empty manifest. 1074 1075 * api_client: arvados.api_resources.ArvadosAPIClient | None --- The 1076 Arvados API client object this instance uses to make requests. If 1077 none is given, this instance creates its own client using the 1078 settings from `apiconfig` (see below). If your client instantiates 1079 many Collection objects, you can help limit memory utilization by 1080 calling `arvados.api.api` to construct an 1081 `arvados.api.ThreadSafeAPIClient`, and use that as the `api_client` 1082 for every Collection. 1083 1084 * keep_client: arvados.keep.KeepClient | None --- The Keep client 1085 object this instance uses to make requests. If none is given, this 1086 instance creates its own client using its `api_client`. 1087 1088 * num_retries: int --- The number of times that client requests are 1089 retried. Default 10. 1090 1091 * parent: arvados.collection.Collection | None --- The parent Collection 1092 object of this instance, if any. This argument is primarily used by 1093 other Collection methods; user client code shouldn't need to use it. 1094 1095 * apiconfig: Mapping[str, str] | None --- A mapping with entries for 1096 `ARVADOS_API_HOST`, `ARVADOS_API_TOKEN`, and optionally 1097 `ARVADOS_API_HOST_INSECURE`. When no `api_client` is provided, the 1098 Collection object constructs one from these settings. If no 1099 mapping is provided, calls `arvados.config.settings` to get these 1100 parameters from user configuration. 1101 1102 * block_manager: arvados.arvfile._BlockManager | None --- The 1103 _BlockManager object used by this instance to coordinate reading 1104 and writing Keep data blocks. If none is given, this instance 1105 constructs its own. This argument is primarily used by other 1106 Collection methods; user client code shouldn't need to use it. 1107 1108 * replication_desired: int | None --- This controls both the value of 1109 the `replication_desired` field on API collection records saved by 1110 this class, as well as the number of Keep services that the object 1111 writes new data blocks to. If none is given, uses the default value 1112 configured for the cluster. 1113 1114 * storage_classes_desired: list[str] | None --- This controls both 1115 the value of the `storage_classes_desired` field on API collection 1116 records saved by this class, as well as selecting which specific 1117 Keep services the object writes new data blocks to. If none is 1118 given, defaults to an empty list. 1119 1120 * put_threads: int | None --- The number of threads to run 1121 simultaneously to upload data blocks to Keep. This value is used when 1122 building a new `block_manager`. It is unused when a `block_manager` 1123 is provided. 1124 """ 1125 1126 if storage_classes_desired and type(storage_classes_desired) is not list: 1127 raise errors.ArgumentError("storage_classes_desired must be list type.") 1128 1129 super(Collection, self).__init__(parent) 1130 self._api_client = api_client 1131 self._keep_client = keep_client 1132 1133 # Use the keep client from ThreadSafeAPIClient 1134 if self._keep_client is None and isinstance(self._api_client, ThreadSafeAPIClient): 1135 self._keep_client = self._api_client.keep 1136 1137 self._block_manager = block_manager 1138 self.replication_desired = replication_desired 1139 self._storage_classes_desired = storage_classes_desired 1140 self.put_threads = put_threads 1141 1142 if apiconfig: 1143 self._config = apiconfig 1144 else: 1145 self._config = config.settings() 1146 1147 self.num_retries = num_retries 1148 self._manifest_locator = None 1149 self._manifest_text = None 1150 self._portable_data_hash = None 1151 self._api_response = None 1152 self._token_refresh_timestamp = 0 1153 1154 self.lock = threading.RLock() 1155 self.events = None 1156 1157 if manifest_locator_or_text: 1158 if re.match(arvados.util.keep_locator_pattern, manifest_locator_or_text): 1159 self._manifest_locator = manifest_locator_or_text 1160 elif re.match(arvados.util.collection_uuid_pattern, manifest_locator_or_text): 1161 self._manifest_locator = manifest_locator_or_text 1162 if not self._has_local_collection_uuid(): 1163 self._has_remote_blocks = True 1164 elif re.match(arvados.util.manifest_pattern, manifest_locator_or_text): 1165 self._manifest_text = manifest_locator_or_text 1166 if '+R' in self._manifest_text: 1167 self._has_remote_blocks = True 1168 else: 1169 raise errors.ArgumentError( 1170 "Argument to CollectionReader is not a manifest or a collection UUID") 1171 1172 try: 1173 self._populate() 1174 except errors.SyntaxError as e: 1175 raise errors.ArgumentError("Error processing manifest text: %s", str(e)) from None
Initialize a Collection object
Arguments:
manifest_locator_or_text: str | None — This string can contain a collection manifest text, portable data hash, or UUID. When given a portable data hash or UUID, this instance will load a collection record from the API server. Otherwise, this instance will represent a new collection without an API server record. The default value
Noneinstantiates a new collection with an empty manifest.api_client: arvados.api_resources.ArvadosAPIClient | None — The Arvados API client object this instance uses to make requests. If none is given, this instance creates its own client using the settings from
apiconfig(see below). If your client instantiates many Collection objects, you can help limit memory utilization by callingarvados.api.apito construct anarvados.api.ThreadSafeAPIClient, and use that as theapi_clientfor every Collection.keep_client: arvados.keep.KeepClient | None — The Keep client object this instance uses to make requests. If none is given, this instance creates its own client using its
api_client.num_retries: int — The number of times that client requests are retried. Default 10.
parent: arvados.collection.Collection | None — The parent Collection object of this instance, if any. This argument is primarily used by other Collection methods; user client code shouldn’t need to use it.
apiconfig: Mapping[str, str] | None — A mapping with entries for
ARVADOS_API_HOST,ARVADOS_API_TOKEN, and optionallyARVADOS_API_HOST_INSECURE. When noapi_clientis provided, the Collection object constructs one from these settings. If no mapping is provided, callsarvados.config.settingsto get these parameters from user configuration.block_manager: arvados.arvfile._BlockManager | None — The _BlockManager object used by this instance to coordinate reading and writing Keep data blocks. If none is given, this instance constructs its own. This argument is primarily used by other Collection methods; user client code shouldn’t need to use it.
replication_desired: int | None — This controls both the value of the
replication_desiredfield on API collection records saved by this class, as well as the number of Keep services that the object writes new data blocks to. If none is given, uses the default value configured for the cluster.storage_classes_desired: list[str] | None — This controls both the value of the
storage_classes_desiredfield on API collection records saved by this class, as well as selecting which specific Keep services the object writes new data blocks to. If none is given, defaults to an empty list.put_threads: int | None — The number of threads to run simultaneously to upload data blocks to Keep. This value is used when building a new
block_manager. It is unused when ablock_manageris provided.
1177 def storage_classes_desired(self) -> List[str]: 1178 """Get this collection's `storage_classes_desired` value""" 1179 return self._storage_classes_desired or []
Get this collection’s storage_classes_desired value
Get this collection’s root collection object
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the source Collection object.
1184 def get_properties(self) -> Properties: 1185 """Get this collection's properties 1186 1187 This method always returns a dict. If this collection object does not 1188 have an associated API record, or that record does not have any 1189 properties set, this method returns an empty dict. 1190 """ 1191 if self._api_response and self._api_response["properties"]: 1192 return self._api_response["properties"] 1193 else: 1194 return {}
Get this collection’s properties
This method always returns a dict. If this collection object does not have an associated API record, or that record does not have any properties set, this method returns an empty dict.
1196 def get_trash_at(self) -> Optional[datetime.datetime]: 1197 """Get this collection's `trash_at` field 1198 1199 This method parses the `trash_at` field of the collection's API 1200 record and returns a datetime from it. If that field is not set, or 1201 this collection object does not have an associated API record, 1202 returns None. 1203 """ 1204 if self._api_response and self._api_response["trash_at"]: 1205 try: 1206 return ciso8601.parse_datetime(self._api_response["trash_at"]) 1207 except ValueError: 1208 return None 1209 else: 1210 return None
Get this collection’s trash_at field
This method parses the trash_at field of the collection’s API
record and returns a datetime from it. If that field is not set, or
this collection object does not have an associated API record,
returns None.
Get the name of the manifest stream represented by this collection
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the name of the stream you opened.
Indicate whether this collection object can be modified
This method returns False if this object is a CollectionReader,
else True.
1218 @synchronized 1219 @retry_method 1220 def update( 1221 self, 1222 other: Optional['Collection']=None, 1223 num_retries: Optional[int]=None, 1224 ) -> None: 1225 """Merge another collection's contents into this one 1226 1227 This method compares the manifest of this collection instance with 1228 another, then updates this instance's manifest with changes from the 1229 other, renaming files to flag conflicts where necessary. 1230 1231 When called without any arguments, this method reloads the collection's 1232 API record, and updates this instance with any changes that have 1233 appeared server-side. If this instance does not have a corresponding 1234 API record, this method raises `arvados.errors.ArgumentError`. 1235 1236 Arguments: 1237 1238 * other: arvados.collection.Collection | None --- The collection 1239 whose contents should be merged into this instance. When not 1240 provided, this method reloads this collection's API record and 1241 constructs a Collection object from it. If this instance does not 1242 have a corresponding API record, this method raises 1243 `arvados.errors.ArgumentError`. 1244 1245 * num_retries: int | None --- The number of times to retry reloading 1246 the collection's API record from the API server. If not specified, 1247 uses the `num_retries` provided when this instance was constructed. 1248 """ 1249 1250 token_refresh_period = 60*60 1251 time_since_last_token_refresh = (time.time() - self._token_refresh_timestamp) 1252 upstream_response = None 1253 1254 if other is None: 1255 if self._manifest_locator is None: 1256 raise errors.ArgumentError("`other` is None but collection does not have a manifest_locator uuid") 1257 1258 if re.match(arvados.util.portable_data_hash_pattern, self._manifest_locator) and time_since_last_token_refresh < token_refresh_period: 1259 return 1260 1261 upstream_response = self._my_api().collections().get(uuid=self._manifest_locator).execute(num_retries=num_retries) 1262 other = CollectionReader(upstream_response["manifest_text"], self._my_api()) 1263 1264 if self.committed(): 1265 # 1st case, no local changes, content is the same 1266 if self.portable_data_hash() == other.portable_data_hash() and time_since_last_token_refresh < token_refresh_period: 1267 # No difference in content. Remember the API record 1268 # (metadata such as name or properties may have changed) 1269 # but don't update the token refresh timestamp. 1270 if upstream_response is not None: 1271 self._remember_api_response(upstream_response) 1272 return 1273 1274 # 2nd case, no local changes, but either upstream changed 1275 # or we want to refresh tokens. 1276 1277 self.apply(self.diff(other)) 1278 if upstream_response is not None: 1279 self._remember_api_response(upstream_response) 1280 self._update_token_timestamp() 1281 self.set_committed(True) 1282 return 1283 1284 # 3rd case, upstream changed, but we also have uncommitted 1285 # changes that we want to incorporate so they don't get lost. 1286 1287 # _manifest_text stores the text from last time we received a 1288 # record from the API server. This is the state of the 1289 # collection before our uncommitted changes. 1290 baseline = Collection(self._manifest_text, self._my_api()) 1291 1292 # Get the set of changes between our baseline and the other 1293 # collection and apply them to self. 1294 # 1295 # If a file was modified in both 'self' and 'other', the 1296 # 'apply' method keeps the contents of 'self' and creates a 1297 # conflict file with the contents of 'other'. 1298 self.apply(baseline.diff(other)) 1299 1300 # Remember the new baseline, changes to a file 1301 if upstream_response is not None: 1302 self._remember_api_response(upstream_response)
Merge another collection’s contents into this one
This method compares the manifest of this collection instance with another, then updates this instance’s manifest with changes from the other, renaming files to flag conflicts where necessary.
When called without any arguments, this method reloads the collection’s
API record, and updates this instance with any changes that have
appeared server-side. If this instance does not have a corresponding
API record, this method raises arvados.errors.ArgumentError.
Arguments:
other: arvados.collection.Collection | None — The collection whose contents should be merged into this instance. When not provided, this method reloads this collection’s API record and constructs a Collection object from it. If this instance does not have a corresponding API record, this method raises
arvados.errors.ArgumentError.num_retries: int | None — The number of times to retry reloading the collection’s API record from the API server. If not specified, uses the
num_retriesprovided when this instance was constructed.
1394 def stop_threads(self) -> None: 1395 """Stop background Keep upload/download threads""" 1396 if self._block_manager is not None: 1397 self._block_manager.stop_threads()
Stop background Keep upload/download threads
1399 @synchronized 1400 def manifest_locator(self) -> Optional[str]: 1401 """Get this collection's manifest locator, if any 1402 1403 * If this collection instance is associated with an API record with a 1404 UUID, return that. 1405 * Otherwise, if this collection instance was loaded from an API record 1406 by portable data hash, return that. 1407 * Otherwise, return `None`. 1408 """ 1409 return self._manifest_locator
Get this collection’s manifest locator, if any
- If this collection instance is associated with an API record with a UUID, return that.
- Otherwise, if this collection instance was loaded from an API record by portable data hash, return that.
- Otherwise, return
None.
1411 @synchronized 1412 def clone( 1413 self, 1414 new_parent: Optional['Collection']=None, 1415 new_name: Optional[str]=None, 1416 readonly: bool=False, 1417 new_config: Optional[Mapping[str, str]]=None, 1418 ) -> 'Collection': 1419 """Create a Collection object with the same contents as this instance 1420 1421 This method creates a new Collection object with contents that match 1422 this instance's. The new collection will not be associated with any API 1423 record. 1424 1425 Arguments: 1426 1427 * new_parent: arvados.collection.Collection | None --- This value is 1428 passed to the new Collection's constructor as the `parent` 1429 argument. 1430 1431 * new_name: str | None --- This value is unused. 1432 1433 * readonly: bool --- If this value is true, this method constructs and 1434 returns a `CollectionReader`. Otherwise, it returns a mutable 1435 `Collection`. Default `False`. 1436 1437 * new_config: Mapping[str, str] | None --- This value is passed to the 1438 new Collection's constructor as `apiconfig`. If no value is provided, 1439 defaults to the configuration passed to this instance's constructor. 1440 """ 1441 if new_config is None: 1442 new_config = self._config 1443 if readonly: 1444 newcollection = CollectionReader(parent=new_parent, apiconfig=new_config) 1445 else: 1446 newcollection = Collection(parent=new_parent, apiconfig=new_config) 1447 1448 newcollection._clonefrom(self) 1449 return newcollection
Create a Collection object with the same contents as this instance
This method creates a new Collection object with contents that match this instance’s. The new collection will not be associated with any API record.
Arguments:
new_parent: arvados.collection.Collection | None — This value is passed to the new Collection’s constructor as the
parentargument.new_name: str | None — This value is unused.
readonly: bool — If this value is true, this method constructs and returns a
CollectionReader. Otherwise, it returns a mutableCollection. DefaultFalse.new_config: Mapping[str, str] | None — This value is passed to the new Collection’s constructor as
apiconfig. If no value is provided, defaults to the configuration passed to this instance’s constructor.
1451 @synchronized 1452 def api_response(self) -> Optional[Dict[str, Any]]: 1453 """Get this instance's associated API record 1454 1455 If this Collection instance has an associated API record, return it. 1456 Otherwise, return `None`. 1457 """ 1458 return self._api_response
Get this instance’s associated API record
If this Collection instance has an associated API record, return it.
Otherwise, return None.
1460 def find_or_create( 1461 self, 1462 path: str, 1463 create_type: CreateType, 1464 ) -> CollectionItem: 1465 if path == ".": 1466 return self 1467 else: 1468 return super(Collection, self).find_or_create(path[2:] if path.startswith("./") else path, create_type)
Get the item at the given path, creating it if necessary
If path refers to a stream in this collection, returns a
corresponding Subcollection object. If path refers to a file in
this collection, returns a corresponding
arvados.arvfile.ArvadosFile object. If path does not exist in
this collection, then this method creates a new object and returns
it, creating parent streams as needed. The type of object created is
determined by the value of create_type.
Arguments:
path: str — The path to find or create within this collection.
create_type: Literal[COLLECTION, FILE] — The type of object to create at
pathif one does not exist. PassingCOLLECTIONcreates a stream and returns the correspondingSubcollection. PassingFILEcreates a new file and returns the correspondingarvados.arvfile.ArvadosFile.
1470 def find(self, path: str) -> CollectionItem | None: 1471 if path == ".": 1472 return self 1473 else: 1474 return super(Collection, self).find(path[2:] if path.startswith("./") else path)
Get the item at the given path.
If path refers to a stream in this collection, returns a
corresponding Subcollection object.
As a special case, if path is ".", returns the collection itself.
If path refers to a file in this collection, returns a corresponding
arvados.arvfile.ArvadosFile object.
If path does not exist in this collection, then this method returns
None.
A path that begins with the slash (/) character is invalid;
NotADirectoryError is raised in this case.
An empty path is invalid, and arvados.errors.ArgumentError is
raised.
Arguments:
- path: str — The path to find within this collection.
1476 def remove(self, path: str, recursive: bool=False) -> None: 1477 if path == ".": 1478 raise errors.ArgumentError("Cannot remove '.'") 1479 else: 1480 return super(Collection, self).remove(path[2:] if path.startswith("./") else path, recursive)
Remove the file or stream at path
Arguments:
path: str — The path of the item to remove from the collection
recursive: bool — Controls the method’s behavior if
pathrefers to a nonempty stream. IfFalse(the default), this method raisesOSErrorwith errnoENOTEMPTY. IfTrue, this method removes all items under the stream.
1482 @must_be_writable 1483 @synchronized 1484 @retry_method 1485 def save( 1486 self, 1487 properties: Optional[Properties]=None, 1488 storage_classes: Optional[StorageClasses]=None, 1489 trash_at: Optional[datetime.datetime]=None, 1490 merge: bool=True, 1491 num_retries: Optional[int]=None, 1492 preserve_version: bool=False, 1493 ) -> str: 1494 """Save collection to an existing API record 1495 1496 This method updates the instance's corresponding API record to match 1497 the instance's state. If this instance does not have a corresponding API 1498 record yet, raises `AssertionError`. (To create a new API record, use 1499 `Collection.save_new`.) This method returns the saved collection 1500 manifest. 1501 1502 Arguments: 1503 1504 * properties: dict[str, Any] | None --- If provided, the API record will 1505 be updated with these properties. Note this will completely replace 1506 any existing properties. 1507 1508 * storage_classes: list[str] | None --- If provided, the API record will 1509 be updated with this value in the `storage_classes_desired` field. 1510 This value will also be saved on the instance and used for any 1511 changes that follow. 1512 1513 * trash_at: datetime.datetime | None --- If provided, the API record 1514 will be updated with this value in the `trash_at` field. 1515 1516 * merge: bool --- If `True` (the default), this method will first 1517 reload this collection's API record, and merge any new contents into 1518 this instance before saving changes. See `Collection.update` for 1519 details. 1520 1521 * num_retries: int | None --- The number of times to retry reloading 1522 the collection's API record from the API server. If not specified, 1523 uses the `num_retries` provided when this instance was constructed. 1524 1525 * preserve_version: bool --- This value will be passed to directly 1526 to the underlying API call. If `True`, the Arvados API will 1527 preserve the versions of this collection both immediately before 1528 and after the update. If `True` when the API server is not 1529 configured with collection versioning, this method raises 1530 `arvados.errors.ArgumentError`. 1531 """ 1532 if properties and type(properties) is not dict: 1533 raise errors.ArgumentError("properties must be dictionary type.") 1534 1535 if storage_classes and type(storage_classes) is not list: 1536 raise errors.ArgumentError("storage_classes must be list type.") 1537 if storage_classes: 1538 self._storage_classes_desired = storage_classes 1539 1540 if trash_at and type(trash_at) is not datetime.datetime: 1541 raise errors.ArgumentError("trash_at must be datetime type.") 1542 1543 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1544 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1545 1546 body={} 1547 if properties: 1548 body["properties"] = properties 1549 if self.storage_classes_desired(): 1550 body["storage_classes_desired"] = self.storage_classes_desired() 1551 if trash_at: 1552 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1553 body["trash_at"] = t 1554 if preserve_version: 1555 body["preserve_version"] = preserve_version 1556 1557 if not self.committed(): 1558 if self._has_remote_blocks: 1559 # Copy any remote blocks to the local cluster. 1560 self._copy_remote_blocks(remote_blocks={}) 1561 self._has_remote_blocks = False 1562 if not self._has_collection_uuid(): 1563 raise AssertionError("Collection manifest_locator is not a collection uuid. Use save_new() for new collections.") 1564 elif not self._has_local_collection_uuid(): 1565 raise AssertionError("Collection manifest_locator is from a remote cluster. Use save_new() to save it on the local cluster.") 1566 1567 self._my_block_manager().commit_all() 1568 1569 if merge: 1570 self.update() 1571 1572 text = self.manifest_text(strip=False) 1573 body['manifest_text'] = text 1574 1575 self._remember_api_response(self._my_api().collections().update( 1576 uuid=self._manifest_locator, 1577 body=body 1578 ).execute(num_retries=num_retries)) 1579 self.set_committed(True) 1580 elif body: 1581 self._remember_api_response(self._my_api().collections().update( 1582 uuid=self._manifest_locator, 1583 body=body 1584 ).execute(num_retries=num_retries)) 1585 1586 return self._manifest_text
Save collection to an existing API record
This method updates the instance’s corresponding API record to match
the instance’s state. If this instance does not have a corresponding API
record yet, raises AssertionError. (To create a new API record, use
Collection.save_new.) This method returns the saved collection
manifest.
Arguments:
properties: dict[str, Any] | None — If provided, the API record will be updated with these properties. Note this will completely replace any existing properties.
storage_classes: list[str] | None — If provided, the API record will be updated with this value in the
storage_classes_desiredfield. This value will also be saved on the instance and used for any changes that follow.trash_at: datetime.datetime | None — If provided, the API record will be updated with this value in the
trash_atfield.merge: bool — If
True(the default), this method will first reload this collection’s API record, and merge any new contents into this instance before saving changes. SeeCollection.updatefor details.num_retries: int | None — The number of times to retry reloading the collection’s API record from the API server. If not specified, uses the
num_retriesprovided when this instance was constructed.preserve_version: bool — This value will be passed to directly to the underlying API call. If
True, the Arvados API will preserve the versions of this collection both immediately before and after the update. IfTruewhen the API server is not configured with collection versioning, this method raisesarvados.errors.ArgumentError.
1589 @must_be_writable 1590 @synchronized 1591 @retry_method 1592 def save_new( 1593 self, 1594 name: Optional[str]=None, 1595 create_collection_record: bool=True, 1596 owner_uuid: Optional[str]=None, 1597 properties: Optional[Properties]=None, 1598 storage_classes: Optional[StorageClasses]=None, 1599 trash_at: Optional[datetime.datetime]=None, 1600 ensure_unique_name: bool=False, 1601 num_retries: Optional[int]=None, 1602 preserve_version: bool=False, 1603 ): 1604 """Save collection to a new API record 1605 1606 This method finishes uploading new data blocks and (optionally) 1607 creates a new API collection record with the provided data. If a new 1608 record is created, this instance becomes associated with that record 1609 for future updates like `save()`. This method returns the saved 1610 collection manifest. 1611 1612 Arguments: 1613 1614 * name: str | None --- The `name` field to use on the new collection 1615 record. If not specified, a generic default name is generated. 1616 1617 * create_collection_record: bool --- If `True` (the default), creates a 1618 collection record on the API server. If `False`, the method finishes 1619 all data uploads and only returns the resulting collection manifest 1620 without sending it to the API server. 1621 1622 * owner_uuid: str | None --- The `owner_uuid` field to use on the 1623 new collection record. 1624 1625 * properties: dict[str, Any] | None --- The `properties` field to use on 1626 the new collection record. 1627 1628 * storage_classes: list[str] | None --- The 1629 `storage_classes_desired` field to use on the new collection record. 1630 1631 * trash_at: datetime.datetime | None --- The `trash_at` field to use 1632 on the new collection record. 1633 1634 * ensure_unique_name: bool --- This value is passed directly to the 1635 Arvados API when creating the collection record. If `True`, the API 1636 server may modify the submitted `name` to ensure the collection's 1637 `name`+`owner_uuid` combination is unique. If `False` (the default), 1638 if a collection already exists with this same `name`+`owner_uuid` 1639 combination, creating a collection record will raise a validation 1640 error. 1641 1642 * num_retries: int | None --- The number of times to retry reloading 1643 the collection's API record from the API server. If not specified, 1644 uses the `num_retries` provided when this instance was constructed. 1645 1646 * preserve_version: bool --- This value will be passed to directly 1647 to the underlying API call. If `True`, the Arvados API will 1648 preserve the versions of this collection both immediately before 1649 and after the update. If `True` when the API server is not 1650 configured with collection versioning, this method raises 1651 `arvados.errors.ArgumentError`. 1652 """ 1653 if properties and type(properties) is not dict: 1654 raise errors.ArgumentError("properties must be dictionary type.") 1655 1656 if storage_classes and type(storage_classes) is not list: 1657 raise errors.ArgumentError("storage_classes must be list type.") 1658 1659 if trash_at and type(trash_at) is not datetime.datetime: 1660 raise errors.ArgumentError("trash_at must be datetime type.") 1661 1662 if preserve_version and not self._my_api().config()['Collections'].get('CollectionVersioning', False): 1663 raise errors.ArgumentError("preserve_version is not supported when CollectionVersioning is not enabled.") 1664 1665 if self._has_remote_blocks: 1666 # Copy any remote blocks to the local cluster. 1667 self._copy_remote_blocks(remote_blocks={}) 1668 self._has_remote_blocks = False 1669 1670 if storage_classes: 1671 self._storage_classes_desired = storage_classes 1672 1673 self._my_block_manager().commit_all() 1674 text = self.manifest_text(strip=False) 1675 1676 if create_collection_record: 1677 if name is None: 1678 name = "New collection" 1679 ensure_unique_name = True 1680 1681 body = {"manifest_text": text, 1682 "name": name, 1683 "replication_desired": self.replication_desired} 1684 if owner_uuid: 1685 body["owner_uuid"] = owner_uuid 1686 if properties: 1687 body["properties"] = properties 1688 if self.storage_classes_desired(): 1689 body["storage_classes_desired"] = self.storage_classes_desired() 1690 if trash_at: 1691 t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") 1692 body["trash_at"] = t 1693 if preserve_version: 1694 body["preserve_version"] = preserve_version 1695 1696 self._remember_api_response(self._my_api().collections().create(ensure_unique_name=ensure_unique_name, body=body).execute(num_retries=num_retries)) 1697 self._manifest_locator = self._api_response["uuid"] 1698 self.set_committed(True) 1699 1700 return text
Save collection to a new API record
This method finishes uploading new data blocks and (optionally)
creates a new API collection record with the provided data. If a new
record is created, this instance becomes associated with that record
for future updates like save(). This method returns the saved
collection manifest.
Arguments:
name: str | None — The
namefield to use on the new collection record. If not specified, a generic default name is generated.create_collection_record: bool — If
True(the default), creates a collection record on the API server. IfFalse, the method finishes all data uploads and only returns the resulting collection manifest without sending it to the API server.owner_uuid: str | None — The
owner_uuidfield to use on the new collection record.properties: dict[str, Any] | None — The
propertiesfield to use on the new collection record.storage_classes: list[str] | None — The
storage_classes_desiredfield to use on the new collection record.trash_at: datetime.datetime | None — The
trash_atfield to use on the new collection record.ensure_unique_name: bool — This value is passed directly to the Arvados API when creating the collection record. If
True, the API server may modify the submittednameto ensure the collection’sname+owner_uuidcombination is unique. IfFalse(the default), if a collection already exists with this samename+owner_uuidcombination, creating a collection record will raise a validation error.num_retries: int | None — The number of times to retry reloading the collection’s API record from the API server. If not specified, uses the
num_retriesprovided when this instance was constructed.preserve_version: bool — This value will be passed to directly to the underlying API call. If
True, the Arvados API will preserve the versions of this collection both immediately before and after the update. IfTruewhen the API server is not configured with collection versioning, this method raisesarvados.errors.ArgumentError.
1784 @synchronized 1785 def notify( 1786 self, 1787 event: ChangeType, 1788 collection: 'RichCollectionBase', 1789 name: str, 1790 item: CollectionItem, 1791 ) -> None: 1792 if self._callback: 1793 self._callback(event, collection, name, item)
Notify any subscribed callback about a change to this collection
If a callback has been registered with RichCollectionBase.subscribe,
it will be called with information about a change to this collection.
Then this notification will be propagated to this collection’s root.
Arguments:
event: Literal[ADD, DEL, MOD, TOK] — The type of modification to the collection.
collection: arvados.collection.RichCollectionBase — The collection that was modified.
name: str — The name of the file or stream within
collectionthat was modified.item: arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — For ADD events, the new contents at
namewithincollection; for DEL events, the item that was removed. For MOD and TOK events, a 2-tuple of the previous item and the new item (may be the same object or different, depending on whether the action involved it being modified in place or replaced).
1796class Subcollection(RichCollectionBase): 1797 """Read and manipulate a stream/directory within an Arvados collection 1798 1799 This class represents a single stream (like a directory) within an Arvados 1800 `Collection`. It is returned by `Collection.find` and provides the same API. 1801 Operations that work on the API collection record propagate to the parent 1802 `Collection` object. 1803 """ 1804 1805 def __init__(self, parent, name): 1806 super(Subcollection, self).__init__(parent) 1807 self.lock = self.root_collection().lock 1808 self._manifest_text = None 1809 self.name = name 1810 self.num_retries = parent.num_retries 1811 1812 def root_collection(self) -> 'Collection': 1813 return self.parent.root_collection() 1814 1815 def writable(self) -> bool: 1816 return self.root_collection().writable() 1817 1818 def _my_api(self): 1819 return self.root_collection()._my_api() 1820 1821 def _my_keep(self): 1822 return self.root_collection()._my_keep() 1823 1824 def _my_block_manager(self): 1825 return self.root_collection()._my_block_manager() 1826 1827 def stream_name(self) -> str: 1828 return os.path.join(self.parent.stream_name(), self.name) 1829 1830 @synchronized 1831 def clone( 1832 self, 1833 new_parent: Optional['Collection']=None, 1834 new_name: Optional[str]=None, 1835 ) -> 'Subcollection': 1836 c = Subcollection(new_parent, new_name) 1837 c._clonefrom(self) 1838 return c 1839 1840 @must_be_writable 1841 @synchronized 1842 def _reparent(self, newparent, newname): 1843 self.set_committed(False) 1844 self.flush() 1845 self.parent.remove(self.name, recursive=True) 1846 self.parent = newparent 1847 self.name = newname 1848 self.lock = self.parent.root_collection().lock 1849 1850 @synchronized 1851 def _get_manifest_text(self, stream_name, strip, normalize, only_committed=False): 1852 """Encode empty directories by using an \056-named (".") empty file""" 1853 if len(self._items) == 0: 1854 return "%s %s 0:0:\\056\n" % ( 1855 streams.escape(stream_name), config.EMPTY_BLOCK_LOCATOR) 1856 return super(Subcollection, self)._get_manifest_text(stream_name, 1857 strip, normalize, 1858 only_committed)
Read and manipulate a stream/directory within an Arvados collection
This class represents a single stream (like a directory) within an Arvados
Collection. It is returned by Collection.find and provides the same API.
Operations that work on the API collection record propagate to the parent
Collection object.
Get this collection’s root collection object
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the source Collection object.
Indicate whether this collection object can be modified
This method returns False if this object is a CollectionReader,
else True.
Get the name of the manifest stream represented by this collection
If you open a subcollection with Collection.find, calling this method
on that subcollection returns the name of the stream you opened.
Inherited Members
1861class CollectionReader(Collection): 1862 """Read-only `Collection` subclass 1863 1864 This class will never create or update any API collection records. You can 1865 use this class for additional code safety when you only need to read 1866 existing collections. 1867 """ 1868 def __init__(self, manifest_locator_or_text, *args, **kwargs): 1869 self._in_init = True 1870 super(CollectionReader, self).__init__(manifest_locator_or_text, *args, **kwargs) 1871 self._in_init = False 1872 1873 # Forego any locking since it should never change once initialized. 1874 self.lock = NoopLock() 1875 1876 # Backwards compatability with old CollectionReader 1877 # all_streams() and all_files() 1878 self._streams = None 1879 1880 def writable(self) -> bool: 1881 return self._in_init
Read-only Collection subclass
This class will never create or update any API collection records. You can use this class for additional code safety when you only need to read existing collections.
1868 def __init__(self, manifest_locator_or_text, *args, **kwargs): 1869 self._in_init = True 1870 super(CollectionReader, self).__init__(manifest_locator_or_text, *args, **kwargs) 1871 self._in_init = False 1872 1873 # Forego any locking since it should never change once initialized. 1874 self.lock = NoopLock() 1875 1876 # Backwards compatability with old CollectionReader 1877 # all_streams() and all_files() 1878 self._streams = None
Initialize a Collection object
Arguments:
manifest_locator_or_text: str | None — This string can contain a collection manifest text, portable data hash, or UUID. When given a portable data hash or UUID, this instance will load a collection record from the API server. Otherwise, this instance will represent a new collection without an API server record. The default value
Noneinstantiates a new collection with an empty manifest.api_client: arvados.api_resources.ArvadosAPIClient | None — The Arvados API client object this instance uses to make requests. If none is given, this instance creates its own client using the settings from
apiconfig(see below). If your client instantiates many Collection objects, you can help limit memory utilization by callingarvados.api.apito construct anarvados.api.ThreadSafeAPIClient, and use that as theapi_clientfor every Collection.keep_client: arvados.keep.KeepClient | None — The Keep client object this instance uses to make requests. If none is given, this instance creates its own client using its
api_client.num_retries: int — The number of times that client requests are retried. Default 10.
parent: arvados.collection.Collection | None — The parent Collection object of this instance, if any. This argument is primarily used by other Collection methods; user client code shouldn’t need to use it.
apiconfig: Mapping[str, str] | None — A mapping with entries for
ARVADOS_API_HOST,ARVADOS_API_TOKEN, and optionallyARVADOS_API_HOST_INSECURE. When noapi_clientis provided, the Collection object constructs one from these settings. If no mapping is provided, callsarvados.config.settingsto get these parameters from user configuration.block_manager: arvados.arvfile._BlockManager | None — The _BlockManager object used by this instance to coordinate reading and writing Keep data blocks. If none is given, this instance constructs its own. This argument is primarily used by other Collection methods; user client code shouldn’t need to use it.
replication_desired: int | None — This controls both the value of the
replication_desiredfield on API collection records saved by this class, as well as the number of Keep services that the object writes new data blocks to. If none is given, uses the default value configured for the cluster.storage_classes_desired: list[str] | None — This controls both the value of the
storage_classes_desiredfield on API collection records saved by this class, as well as selecting which specific Keep services the object writes new data blocks to. If none is given, defaults to an empty list.put_threads: int | None — The number of threads to run simultaneously to upload data blocks to Keep. This value is used when building a new
block_manager. It is unused when ablock_manageris provided.
Indicate whether this collection object can be modified
This method returns False if this object is a CollectionReader,
else True.