X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/93e4548df0ba8e98589dea7cdde60a851a2d9c2f..2e4ec565fc74e6de19fefd2a8670fd319544ce73:/sdk/python/arvados/collection.py?ds=sidebyside diff --git a/sdk/python/arvados/collection.py b/sdk/python/arvados/collection.py index d4f24047d2..5b1f1a2a67 100644 --- a/sdk/python/arvados/collection.py +++ b/sdk/python/arvados/collection.py @@ -1,24 +1,32 @@ +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + from __future__ import absolute_import from future.utils import listitems, listvalues, viewkeys from builtins import str from past.builtins import basestring from builtins import object +import ciso8601 +import datetime +import errno import functools +import hashlib +import io import logging import os import re -import errno -import hashlib -import time +import sys import threading +import time from collections import deque from stat import * -from .arvfile import split, _FileLikeObjectBase, ArvadosFile, ArvadosFileWriter, ArvadosFileReader, _BlockManager, synchronized, must_be_writable, NoopLock +from .arvfile import split, _FileLikeObjectBase, ArvadosFile, ArvadosFileWriter, ArvadosFileReader, WrappableFile, _BlockManager, synchronized, must_be_writable, NoopLock from .keep import KeepLocator, KeepClient from .stream import StreamReader -from ._normalize_stream import normalize_stream +from ._normalize_stream import normalize_stream, escape from ._ranges import Range, LocatorAndRange from .safeapi import ThreadSafeApiCache import arvados.config as config @@ -29,7 +37,24 @@ from arvados.retry import retry_method _logger = logging.getLogger('arvados.collection') + +if sys.version_info >= (3, 0): + TextIOWrapper = io.TextIOWrapper +else: + class TextIOWrapper(io.TextIOWrapper): + """To maintain backward compatibility, cast str to unicode in + write('foo'). + + """ + def write(self, data): + if isinstance(data, basestring): + data = unicode(data) + return super(TextIOWrapper, self).write(data) + + class CollectionBase(object): + """Abstract base class for Collection classes.""" + def __enter__(self): return self @@ -87,6 +112,8 @@ class _WriterFile(_FileLikeObjectBase): class CollectionWriter(CollectionBase): + """Deprecated, use Collection instead.""" + def __init__(self, api_client=None, num_retries=0, replication=None): """Instantiate a CollectionWriter. @@ -382,8 +409,18 @@ class CollectionWriter(CollectionBase): ret += locators return ret + def save_new(self, name=None): + return self._api_client.collections().create( + ensure_unique_name=True, + body={ + 'name': name, + 'manifest_text': self.manifest_text(), + }).execute(num_retries=self.num_retries) + class ResumableCollectionWriter(CollectionWriter): + """Deprecated, use Collection instead.""" + STATE_PROPS = ['_current_stream_files', '_current_stream_length', '_current_stream_locators', '_current_stream_name', '_current_file_name', '_current_file_pos', '_close_file', @@ -500,6 +537,7 @@ class RichCollectionBase(CollectionBase): def __init__(self, parent=None): self.parent = parent self._committed = False + self._has_remote_blocks = False self._callback = None self._items = {} @@ -524,6 +562,24 @@ class RichCollectionBase(CollectionBase): def stream_name(self): raise NotImplementedError() + + @synchronized + def has_remote_blocks(self): + """Recursively check for a +R segment locator signature.""" + + if self._has_remote_blocks: + return True + for item in self: + if self[item].has_remote_blocks(): + return True + return False + + @synchronized + def set_has_remote_blocks(self, val): + self._has_remote_blocks = val + if self.parent: + self.parent.set_has_remote_blocks(val) + @must_be_writable @synchronized def find_or_create(self, path, create_type): @@ -616,7 +672,7 @@ class RichCollectionBase(CollectionBase): return self.find_or_create(path, COLLECTION) - def open(self, path, mode="r"): + def open(self, path, mode="r", encoding=None): """Open a file-like object for access. :path: @@ -638,6 +694,7 @@ class RichCollectionBase(CollectionBase): opens for reading and writing. All writes are appended to the end of the file. Writing does not affect the file pointer for reading. + """ if not re.search(r'^[rwa][bt]?\+?$', mode): @@ -660,7 +717,12 @@ class RichCollectionBase(CollectionBase): if mode[0] == 'w': arvfile.truncate(0) - return fclass(arvfile, mode=mode, num_retries=self.num_retries) + binmode = mode[0] + 'b' + re.sub('[bt]', '', mode[1:]) + f = fclass(arvfile, mode=binmode, num_retries=self.num_retries) + if 'b' not in mode: + bufferclass = io.BufferedRandom if f.writable() else io.BufferedReader + f = TextIOWrapper(bufferclass(WrappableFile(f)), encoding=encoding) + return f def modified(self): """Determine if the collection has been modified since last commited.""" @@ -812,6 +874,8 @@ class RichCollectionBase(CollectionBase): self._items[target_name] = item self.set_committed(False) + if not self._has_remote_blocks and source_obj.has_remote_blocks(): + self.set_has_remote_blocks(True) if modified_from: self.notify(MOD, self, target_name, (modified_from, item)) @@ -995,7 +1059,9 @@ class RichCollectionBase(CollectionBase): if stream: buf.append(" ".join(normalize_stream(stream_name, stream)) + "\n") for dirname in [s for s in sorted_keys if isinstance(self[s], RichCollectionBase)]: - buf.append(self[dirname].manifest_text(stream_name=os.path.join(stream_name, dirname), strip=strip, normalize=True, only_committed=only_committed)) + buf.append(self[dirname].manifest_text( + stream_name=os.path.join(stream_name, dirname), + strip=strip, normalize=True, only_committed=only_committed)) return "".join(buf) else: if strip: @@ -1003,6 +1069,24 @@ class RichCollectionBase(CollectionBase): else: return self._manifest_text + @synchronized + def _copy_remote_blocks(self, remote_blocks={}): + """Scan through the entire collection and ask Keep to copy remote blocks. + + When accessing a remote collection, blocks will have a remote signature + (+R instead of +A). Collect these signatures and request Keep to copy the + blocks to the local cluster, returning local (+A) signatures. + + :remote_blocks: + Shared cache of remote to local block mappings. This is used to avoid + doing extra work when blocks are shared by more than one file in + different subdirectories. + + """ + for item in self: + remote_blocks = self[item]._copy_remote_blocks(remote_blocks) + return remote_blocks + @synchronized def diff(self, end_collection, prefix=".", holding_collection=None): """Generate list of add/modify/delete actions. @@ -1085,7 +1169,7 @@ class RichCollectionBase(CollectionBase): return self._portable_data_hash else: stripped = self.portable_manifest_text().encode() - return hashlib.md5(stripped).hexdigest() + '+' + str(len(stripped)) + return '{}+{}'.format(hashlib.md5(stripped).hexdigest(), len(stripped)) @synchronized def subscribe(self, callback): @@ -1181,8 +1265,9 @@ class Collection(RichCollectionBase): """Collection constructor. :manifest_locator_or_text: - One of Arvados collection UUID, block locator of - a manifest, raw manifest text, or None (to create an empty collection). + An Arvados collection UUID, portable data hash, raw manifest + text, or (if creating an empty collection) None. + :parent: the parent Collection, may be None. @@ -1236,8 +1321,12 @@ class Collection(RichCollectionBase): self._manifest_locator = manifest_locator_or_text elif re.match(arvados.util.collection_uuid_pattern, manifest_locator_or_text): self._manifest_locator = manifest_locator_or_text + if not self._has_local_collection_uuid(): + self._has_remote_blocks = True elif re.match(arvados.util.manifest_pattern, manifest_locator_or_text): self._manifest_text = manifest_locator_or_text + if '+R' in self._manifest_text: + self._has_remote_blocks = True else: raise errors.ArgumentError( "Argument to CollectionReader is not a manifest or a collection UUID") @@ -1250,6 +1339,21 @@ class Collection(RichCollectionBase): def root_collection(self): return self + def get_properties(self): + if self._api_response and self._api_response["properties"]: + return self._api_response["properties"] + else: + return {} + + def get_trash_at(self): + if self._api_response and self._api_response["trash_at"]: + try: + return ciso8601.parse_datetime(self._api_response["trash_at"]) + except ValueError: + return None + else: + return None + def stream_name(self): return "." @@ -1321,68 +1425,32 @@ class Collection(RichCollectionBase): # it. If instantiation fails, we'll fall back to the except # clause, just like any other Collection lookup # failure. Return an exception, or None if successful. - try: - self._remember_api_response(self._my_api().collections().get( - uuid=self._manifest_locator).execute( - num_retries=self.num_retries)) - self._manifest_text = self._api_response['manifest_text'] - self._portable_data_hash = self._api_response['portable_data_hash'] - # If not overriden via kwargs, we should try to load the - # replication_desired from the API server - if self.replication_desired is None: - self.replication_desired = self._api_response.get('replication_desired', None) - return None - except Exception as e: - return e - - def _populate_from_keep(self): - # Retrieve a manifest directly from Keep. This has a chance of - # working if [a] the locator includes a permission signature - # or [b] the Keep services are operating in world-readable - # mode. Return an exception, or None if successful. - try: - self._manifest_text = self._my_keep().get( - self._manifest_locator, num_retries=self.num_retries).decode() - except Exception as e: - return e + self._remember_api_response(self._my_api().collections().get( + uuid=self._manifest_locator).execute( + num_retries=self.num_retries)) + self._manifest_text = self._api_response['manifest_text'] + self._portable_data_hash = self._api_response['portable_data_hash'] + # If not overriden via kwargs, we should try to load the + # replication_desired from the API server + if self.replication_desired is None: + self.replication_desired = self._api_response.get('replication_desired', None) def _populate(self): - if self._manifest_locator is None and self._manifest_text is None: - return - error_via_api = None - error_via_keep = None - should_try_keep = ((self._manifest_text is None) and - arvados.util.keep_locator_pattern.match( - self._manifest_locator)) - if ((self._manifest_text is None) and - arvados.util.signed_locator_pattern.match(self._manifest_locator)): - error_via_keep = self._populate_from_keep() - if self._manifest_text is None: - error_via_api = self._populate_from_api_server() - if error_via_api is not None and not should_try_keep: - raise error_via_api - if ((self._manifest_text is None) and - not error_via_keep and - should_try_keep): - # Looks like a keep locator, and we didn't already try keep above - error_via_keep = self._populate_from_keep() if self._manifest_text is None: - # Nothing worked! - raise errors.NotFoundError( - ("Failed to retrieve collection '{}' " + - "from either API server ({}) or Keep ({})." - ).format( - self._manifest_locator, - error_via_api, - error_via_keep)) - # populate + if self._manifest_locator is None: + return + else: + self._populate_from_api_server() self._baseline_manifest = self._manifest_text self._import_manifest(self._manifest_text) - def _has_collection_uuid(self): return self._manifest_locator is not None and re.match(arvados.util.collection_uuid_pattern, self._manifest_locator) + def _has_local_collection_uuid(self): + return self._has_collection_uuid and \ + self._my_api()._rootDesc['uuidPrefix'] == self._manifest_locator.split('-')[0] + def __enter__(self): return self @@ -1457,17 +1525,34 @@ class Collection(RichCollectionBase): @must_be_writable @synchronized @retry_method - def save(self, merge=True, num_retries=None): + def save(self, + properties=None, + storage_classes=None, + trash_at=None, + merge=True, + num_retries=None): """Save collection to an existing collection record. Commit pending buffer blocks to Keep, merge with remote record (if - merge=True, the default), and update the collection record. Returns + merge=True, the default), and update the collection record. Returns the current manifest text. Will raise AssertionError if not associated with a collection record on the API server. If you want to save a manifest to Keep only, see `save_new()`. + :properties: + Additional properties of collection. This value will replace any existing + properties of collection. + + :storage_classes: + Specify desirable storage classes to be used when writing data to Keep. + + :trash_at: + A collection is *expiring* when it has a *trash_at* time in the future. + An expiring collection can be accessed as normal, + but is scheduled to be trashed automatically at the *trash_at* time. + :merge: Update and merge remote changes before saving. Otherwise, any remote changes will be ignored and overwritten. @@ -1476,9 +1561,33 @@ class Collection(RichCollectionBase): Retry count on API calls (if None, use the collection default) """ + if properties and type(properties) is not dict: + raise errors.ArgumentError("properties must be dictionary type.") + + if storage_classes and type(storage_classes) is not list: + raise errors.ArgumentError("storage_classes must be list type.") + + if trash_at and type(trash_at) is not datetime.datetime: + raise errors.ArgumentError("trash_at must be datetime type.") + + body={} + if properties: + body["properties"] = properties + if storage_classes: + body["storage_classes_desired"] = storage_classes + if trash_at: + t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + body["trash_at"] = t + if not self.committed(): + if self._has_remote_blocks: + # Copy any remote blocks to the local cluster. + self._copy_remote_blocks(remote_blocks={}) + self._has_remote_blocks = False if not self._has_collection_uuid(): raise AssertionError("Collection manifest_locator is not a collection uuid. Use save_new() for new collections.") + elif not self._has_local_collection_uuid(): + raise AssertionError("Collection manifest_locator is from a remote cluster. Use save_new() to save it on the local cluster.") self._my_block_manager().commit_all() @@ -1486,14 +1595,20 @@ class Collection(RichCollectionBase): self.update() text = self.manifest_text(strip=False) + body['manifest_text'] = text + self._remember_api_response(self._my_api().collections().update( uuid=self._manifest_locator, - body={'manifest_text': text} - ).execute( - num_retries=num_retries)) + body=body + ).execute(num_retries=num_retries)) self._manifest_text = self._api_response["manifest_text"] self._portable_data_hash = self._api_response["portable_data_hash"] self.set_committed(True) + elif body: + self._remember_api_response(self._my_api().collections().update( + uuid=self._manifest_locator, + body=body + ).execute(num_retries=num_retries)) return self._manifest_text @@ -1504,6 +1619,9 @@ class Collection(RichCollectionBase): def save_new(self, name=None, create_collection_record=True, owner_uuid=None, + properties=None, + storage_classes=None, + trash_at=None, ensure_unique_name=False, num_retries=None): """Save collection to a new collection record. @@ -1511,7 +1629,7 @@ class Collection(RichCollectionBase): Commit pending buffer blocks to Keep and, when create_collection_record is True (default), create a new collection record. After creating a new collection record, this Collection object will be associated with - the new record used by `save()`. Returns the current manifest text. + the new record used by `save()`. Returns the current manifest text. :name: The collection name. @@ -1524,6 +1642,18 @@ class Collection(RichCollectionBase): the user, or project uuid that will own this collection. If None, defaults to the current user. + :properties: + Additional properties of collection. This value will replace any existing + properties of collection. + + :storage_classes: + Specify desirable storage classes to be used when writing data to Keep. + + :trash_at: + A collection is *expiring* when it has a *trash_at* time in the future. + An expiring collection can be accessed as normal, + but is scheduled to be trashed automatically at the *trash_at* time. + :ensure_unique_name: If True, ask the API server to rename the collection if it conflicts with a collection with the same name and owner. If @@ -1533,6 +1663,20 @@ class Collection(RichCollectionBase): Retry count on API calls (if None, use the collection default) """ + if properties and type(properties) is not dict: + raise errors.ArgumentError("properties must be dictionary type.") + + if storage_classes and type(storage_classes) is not list: + raise errors.ArgumentError("storage_classes must be list type.") + + if trash_at and type(trash_at) is not datetime.datetime: + raise errors.ArgumentError("trash_at must be datetime type.") + + if self._has_remote_blocks: + # Copy any remote blocks to the local cluster. + self._copy_remote_blocks(remote_blocks={}) + self._has_remote_blocks = False + self._my_block_manager().commit_all() text = self.manifest_text(strip=False) @@ -1546,6 +1690,13 @@ class Collection(RichCollectionBase): "replication_desired": self.replication_desired} if owner_uuid: body["owner_uuid"] = owner_uuid + if properties: + body["properties"] = properties + if storage_classes: + body["storage_classes_desired"] = storage_classes + if trash_at: + t = trash_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + body["trash_at"] = t self._remember_api_response(self._my_api().collections().create(ensure_unique_name=ensure_unique_name, body=body).execute(num_retries=num_retries)) text = self._api_response["manifest_text"] @@ -1558,6 +1709,13 @@ class Collection(RichCollectionBase): return text + _token_re = re.compile(r'(\S+)(\s+|$)') + _block_re = re.compile(r'[0-9a-f]{32}\+(\d+)(\+\S+)*') + _segment_re = re.compile(r'(\d+):(\d+):(\S+)') + + def _unescape_manifest_path(self, path): + return re.sub('\\\\([0-3][0-7][0-7])', lambda m: chr(int(m.group(1), 8)), path) + @synchronized def _import_manifest(self, manifest_text): """Import a manifest into a `Collection`. @@ -1576,13 +1734,13 @@ class Collection(RichCollectionBase): stream_name = None state = STREAM_NAME - for token_and_separator in re.finditer(r'(\S+)(\s+|$)', manifest_text): + for token_and_separator in self._token_re.finditer(manifest_text): tok = token_and_separator.group(1) sep = token_and_separator.group(2) if state == STREAM_NAME: # starting a new stream - stream_name = tok.replace('\\040', ' ') + stream_name = self._unescape_manifest_path(tok) blocks = [] segments = [] streamoffset = 0 @@ -1591,7 +1749,7 @@ class Collection(RichCollectionBase): continue if state == BLOCKS: - block_locator = re.match(r'[0-9a-f]{32}\+(\d+)(\+\S+)*', tok) + block_locator = self._block_re.match(tok) if block_locator: blocksize = int(block_locator.group(1)) blocks.append(Range(tok, streamoffset, blocksize, 0)) @@ -1600,17 +1758,22 @@ class Collection(RichCollectionBase): state = SEGMENTS if state == SEGMENTS: - file_segment = re.search(r'^(\d+):(\d+):(\S+)', tok) + file_segment = self._segment_re.match(tok) if file_segment: pos = int(file_segment.group(1)) size = int(file_segment.group(2)) - name = file_segment.group(3).replace('\\040', ' ') - filepath = os.path.join(stream_name, name) - afile = self.find_or_create(filepath, FILE) - if isinstance(afile, ArvadosFile): - afile.add_segment(blocks, pos, size) + name = self._unescape_manifest_path(file_segment.group(3)) + if name.split('/')[-1] == '.': + # placeholder for persisting an empty directory, not a real file + if len(name) > 2: + self.find_or_create(os.path.join(stream_name, name[:-2]), COLLECTION) else: - raise errors.SyntaxError("File %s conflicts with stream of the same name.", filepath) + filepath = os.path.join(stream_name, name) + afile = self.find_or_create(filepath, FILE) + if isinstance(afile, ArvadosFile): + afile.add_segment(blocks, pos, size) + else: + raise errors.SyntaxError("File %s conflicts with stream of the same name.", filepath) else: # error! raise errors.SyntaxError("Invalid manifest format, expected file segment but did not match format: '%s'" % tok) @@ -1676,13 +1839,22 @@ class Subcollection(RichCollectionBase): self.name = newname self.lock = self.parent.root_collection().lock + @synchronized + def _get_manifest_text(self, stream_name, strip, normalize, only_committed=False): + """Encode empty directories by using an \056-named (".") empty file""" + if len(self._items) == 0: + return "%s %s 0:0:\\056\n" % ( + escape(stream_name), config.EMPTY_BLOCK_LOCATOR) + return super(Subcollection, self)._get_manifest_text(stream_name, + strip, normalize, + only_committed) + class CollectionReader(Collection): """A read-only collection object. - Initialize from an api collection record locator, a portable data hash of a - manifest, or raw manifest text. See `Collection` constructor for detailed - options. + Initialize from a collection UUID or portable data hash, or raw + manifest text. See `Collection` constructor for detailed options. """ def __init__(self, manifest_locator_or_text, *args, **kwargs):