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
FILE = 'file'

create_type value for Collection.find_or_create

COLLECTION = 'collection'

create_type value for Collection.find_or_create

ChangeList = typing.List[typing.Union[typing.Tuple[typing.Literal['add', 'del'], str, ForwardRef('Collection')], typing.Tuple[typing.Literal['mod', 'tok'], str, ForwardRef('Collection'), ForwardRef('Collection')]]]
ChangeType = typing.Literal['add', 'del', 'mod', 'tok']
CollectionItem = typing.Union[arvados.arvfile.ArvadosFile, ForwardRef('Collection'), ForwardRef('Subcollection')]
ChangeCallback = typing.Callable[[typing.Literal['add', 'del', 'mod', 'tok'], ForwardRef('Collection'), str, typing.Union[arvados.arvfile.ArvadosFile, ForwardRef('Collection'), ForwardRef('Subcollection')]], object]
CreateType = typing.Literal['collection', 'file']
Properties = typing.Dict[str, typing.Any]
StorageClasses = typing.List[str]
class CollectionBase:
 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

def stripped_manifest(self) -> str:
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.

class RichCollectionBase(CollectionBase):
 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

RichCollectionBase(parent=None)
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 = {}
parent
def writable(self) -> bool:
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.

def root_collection(self) -> Collection:
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.

def stream_name(self) -> str:
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.

@synchronized
def has_remote_blocks(self) -> bool:
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.

@synchronized
def set_has_remote_blocks(self, val: bool) -> None:
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.

@must_be_writable
@synchronized
def find_or_create( self, path: str, create_type: Literal['collection', 'file']) -> Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]:
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 path if one does not exist. Passing COLLECTION creates a stream and returns the corresponding Subcollection. Passing FILE creates a new file and returns the corresponding arvados.arvfile.ArvadosFile.

@synchronized
def find( self, path: str) -> Union[arvados.arvfile.ArvadosFile, Collection, Subcollection, NoneType]:
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.
@synchronized
def mkdirs(self, path: str) -> Subcollection:
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.

def open( self, path: str, mode: str = 'r', encoding: Optional[str] = None) -> <class 'IO'>:
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.

def modified(self) -> bool:
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.

@synchronized
def committed(self):
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.

@synchronized
def set_committed(self, value: bool = True):
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.

@synchronized
def keys(self) -> Iterator[str]:
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.

@synchronized
def values( self) -> List[Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]]:
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.

@synchronized
def items( self) -> List[Tuple[str, Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]]]:
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.

def exists(self, path: str) -> bool:
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
@must_be_writable
@synchronized
def remove(self, path: str, recursive: bool = False) -> None:
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 path refers to a nonempty stream. If False (the default), this method raises OSError with errno ENOTEMPTY. If True, this method removes all items under the stream.

def clone(self):
555    def clone(self):
556        raise NotImplementedError()
@must_be_writable
@synchronized
def add( self, source_obj: Union[arvados.arvfile.ArvadosFile, Collection, Subcollection], target_name: str, overwrite: bool = False, reparent: bool = False) -> None:
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_obj should be added.

  • overwrite: bool — Controls the behavior of this method when the collection already contains an object at target_name. If False (the default), this method will raise FileExistsError. If True, the object at target_name will be replaced with source_obj.

  • reparent: bool — Controls whether this method copies or moves source_obj. If False (the default), source_obj is copied into this collection. If True, source_obj is moved into this collection.

@must_be_writable
@synchronized
def copy( self, source: Union[str, arvados.arvfile.ArvadosFile, Collection, Subcollection], target_path: str, source_collection: Optional[RichCollectionBase] = None, overwrite: bool = False) -> None:
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 source is a str, the object will be found by looking up this path from source_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 source is 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. If False (the default), this method will raise FileExistsError. If True, the object at target_path will be replaced with source_obj.

@must_be_writable
@synchronized
def rename( self, source: Union[str, arvados.arvfile.ArvadosFile, Collection, Subcollection], target_path: str, source_collection: Optional[RichCollectionBase] = None, overwrite: bool = False) -> None:
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 source is a str, the object will be found by looking up this path from source_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 source is 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. If False (the default), this method will raise FileExistsError. If True, the object at target_path will be replaced with source_obj.

def portable_manifest_text(self, stream_name: str = '.') -> str:
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 '.'.
@synchronized
def manifest_text( self, stream_name: str = '.', strip: bool = False, normalize: bool = False, only_committed: bool = False) -> str:
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. If True, 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. If True, 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.

@synchronized
def diff( self, end_collection: RichCollectionBase, prefix: str = '.', holding_collection: Optional[Collection] = None) -> List[Union[Tuple[Literal['add', 'del'], str, Collection], Tuple[Literal['mod', 'tok'], str, Collection, Collection]]]:
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 self to end_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.

@must_be_writable
@synchronized
def apply( self, changes: List[Union[Tuple[Literal['add', 'del'], str, Collection], Tuple[Literal['mod', 'tok'], str, Collection, Collection]]]) -> None:
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:

def portable_data_hash(self) -> str:
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

@synchronized
def subscribe( self, callback: Callable[[Literal['add', 'del', 'mod', 'tok'], Collection, str, Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]], object]) -> None:
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:

@synchronized
def unsubscribe(self) -> None:
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

@synchronized
def notify( self, event: Literal['add', 'del', 'mod', 'tok'], collection: RichCollectionBase, name: str, item: Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]) -> None:
 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 collection that was modified.

  • item: arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — For ADD events, the new contents at name within collection; 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).

@synchronized
def flush(self) -> None:
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
CollectionBase
stripped_manifest
class Collection(RichCollectionBase):
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.

Collection( manifest_locator_or_text: Optional[str] = None, api_client: Optional[arvados.api_resources.ArvadosAPIClient] = None, keep_client: Optional[arvados.keep.KeepClient] = None, num_retries: int = 10, parent: Optional[Collection] = None, apiconfig: Optional[Mapping[str, str]] = None, block_manager: Optional[arvados.arvfile._BlockManager] = None, replication_desired: Optional[int] = None, storage_classes_desired: Optional[List[str]] = None, put_threads: Optional[int] = None)
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 None instantiates 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 calling arvados.api.api to construct an arvados.api.ThreadSafeAPIClient, and use that as the api_client for 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 optionally ARVADOS_API_HOST_INSECURE. When no api_client is provided, the Collection object constructs one from these settings. If no mapping is provided, calls arvados.config.settings to 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_desired field 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_desired field 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 a block_manager is provided.

replication_desired
put_threads
num_retries
lock
events
def storage_classes_desired(self) -> List[str]:
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

def root_collection(self) -> Collection:
1181    def root_collection(self) -> 'Collection':
1182        return self

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.

def get_properties(self) -> Dict[str, Any]:
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.

def get_trash_at(self) -> Optional[datetime.datetime]:
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.

def stream_name(self) -> str:
1212    def stream_name(self) -> str:
1213        return "."

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.

def writable(self) -> bool:
1215    def writable(self) -> bool:
1216        return True

Indicate whether this collection object can be modified

This method returns False if this object is a CollectionReader, else True.

@synchronized
@retry_method
def update( self, other: Optional[Collection] = None, num_retries: Optional[int] = None) -> None:
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_retries provided when this instance was constructed.

def stop_threads(self) -> None:
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

@synchronized
def manifest_locator(self) -> Optional[str]:
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.
@synchronized
def clone( self, new_parent: Optional[Collection] = None, new_name: Optional[str] = None, readonly: bool = False, new_config: Optional[Mapping[str, str]] = None) -> Collection:
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 parent argument.

  • 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 mutable Collection. Default False.

  • 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.

@synchronized
def api_response(self) -> Optional[Dict[str, Any]]:
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.

def find_or_create( self, path: str, create_type: Literal['collection', 'file']) -> Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]:
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 path if one does not exist. Passing COLLECTION creates a stream and returns the corresponding Subcollection. Passing FILE creates a new file and returns the corresponding arvados.arvfile.ArvadosFile.

def find( self, path: str) -> Union[arvados.arvfile.ArvadosFile, Collection, Subcollection, NoneType]:
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.
def remove(self, path: str, recursive: bool = False) -> None:
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 path refers to a nonempty stream. If False (the default), this method raises OSError with errno ENOTEMPTY. If True, this method removes all items under the stream.

@must_be_writable
@synchronized
@retry_method
def save( self, properties: Optional[Dict[str, Any]] = None, storage_classes: Optional[List[str]] = None, trash_at: Optional[datetime.datetime] = None, merge: bool = True, num_retries: Optional[int] = None, preserve_version: bool = False) -> str:
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_desired field. 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_at field.

  • 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. See Collection.update for 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_retries provided 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. If True when the API server is not configured with collection versioning, this method raises arvados.errors.ArgumentError.

@must_be_writable
@synchronized
@retry_method
def save_new( self, name: Optional[str] = None, create_collection_record: bool = True, owner_uuid: Optional[str] = None, properties: Optional[Dict[str, Any]] = None, storage_classes: Optional[List[str]] = None, trash_at: Optional[datetime.datetime] = None, ensure_unique_name: bool = False, num_retries: Optional[int] = None, preserve_version: bool = False):
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 name field 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. If False, 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_uuid field to use on the new collection record.

  • properties: dict[str, Any] | None — The properties field to use on the new collection record.

  • storage_classes: list[str] | None — The storage_classes_desired field to use on the new collection record.

  • trash_at: datetime.datetime | None — The trash_at field 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 submitted name to ensure the collection’s name+owner_uuid combination is unique. If False (the default), if a collection already exists with this same name+owner_uuid combination, 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_retries provided 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. If True when the API server is not configured with collection versioning, this method raises arvados.errors.ArgumentError.

@synchronized
def notify( self, event: Literal['add', 'del', 'mod', 'tok'], collection: RichCollectionBase, name: str, item: Union[arvados.arvfile.ArvadosFile, Collection, Subcollection]) -> None:
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 collection that was modified.

  • item: arvados.arvfile.ArvadosFile | arvados.collection.Subcollection — For ADD events, the new contents at name within collection; 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).

class Subcollection(RichCollectionBase):
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.

Subcollection(parent, name)
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
lock
name
num_retries
def root_collection(self) -> Collection:
1812    def root_collection(self) -> 'Collection':
1813        return self.parent.root_collection()

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.

def writable(self) -> bool:
1815    def writable(self) -> bool:
1816        return self.root_collection().writable()

Indicate whether this collection object can be modified

This method returns False if this object is a CollectionReader, else True.

def stream_name(self) -> str:
1827    def stream_name(self) -> str:
1828        return os.path.join(self.parent.stream_name(), self.name)

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.

@synchronized
def clone( self, new_parent: Optional[Collection] = None, new_name: Optional[str] = None) -> Subcollection:
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
class CollectionReader(Collection):
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.

CollectionReader(manifest_locator_or_text, *args, **kwargs)
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 None instantiates 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 calling arvados.api.api to construct an arvados.api.ThreadSafeAPIClient, and use that as the api_client for 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 optionally ARVADOS_API_HOST_INSECURE. When no api_client is provided, the Collection object constructs one from these settings. If no mapping is provided, calls arvados.config.settings to 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_desired field 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_desired field 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 a block_manager is provided.

lock
def writable(self) -> bool:
1880    def writable(self) -> bool:
1881        return self._in_init

Indicate whether this collection object can be modified

This method returns False if this object is a CollectionReader, else True.