Source code for pywps.inout.basic

##################################################################
# Copyright 2018 Open Source Geospatial Foundation and others    #
# licensed under MIT, Please consult LICENSE.txt for details     #
##################################################################
import base64
import json
import logging
import os
import shutil
import tempfile
import weakref
from collections import namedtuple
from copy import deepcopy
from io import BytesIO, StringIO, open
from pathlib import PurePath
from urllib.parse import urlparse

import humanize
import requests

import pywps.configuration as config
from pywps import OGCUNIT
from pywps.exceptions import (
    FileSizeExceeded,
    FileURLNotSupported,
    InvalidParameterValue,
    NoApplicableCode,
)
from pywps.inout.formats import Supported_Formats
from pywps.inout.literaltypes import (
    LITERAL_DATA_TYPES,
    convert,
    is_anyvalue,
    is_values_reference,
    make_allowedvalues,
)
from pywps.inout.types import Translations
from pywps.translations import lower_case_dict
from pywps.validator import get_validator
from pywps.validator.base import emptyvalidator
from pywps.validator.literalvalidator import (
    validate_allowed_values,
    validate_anyvalue,
    validate_value,
    validate_values_reference,
)
from pywps.validator.mode import MODE

_SOURCE_TYPE = namedtuple('SOURCE_TYPE', 'MEMORY, FILE, STREAM, DATA, URL')
SOURCE_TYPE = _SOURCE_TYPE(0, 1, 2, 3, 4)

LOGGER = logging.getLogger("PYWPS")


def _is_textfile(filename):
    try:
        # use python-magic if available
        import magic
        is_text = 'text/' in magic.from_file(filename, mime=True)
    except ImportError:
        # read the first part of the file to check for a binary indicator.
        # This method won't detect all binary files.
        blocksize = 512
        fh = open(filename, 'rb')
        is_text = b'\x00' not in fh.read(blocksize)
        fh.close()
    return is_text


class UOM(object):
    """
    :param uom: unit of measure
    """

    def __init__(self, uom='', reference=None):
        self.uom = uom
        self.reference = reference

        if self.reference is None:
            self.reference = OGCUNIT.get(self.uom, '')

    @property
    def json(self):
        return {"reference": self.reference,
                "uom": self.uom}

    def __eq__(self, other):
        return self.uom == other.uom


class NoneIOHandler(object):
    """Base class for implementation of IOHandler internal"""

    prop = None

    def __init__(self, ref):
        self._ref = weakref.ref(ref)

    @property
    def file(self):
        """Return filename."""
        return None

    @property
    def data(self):
        """Read file and return content."""
        return None

    @property
    def base64(self):
        """Return base64 encoding of data."""
        return None

    @property
    def stream(self):
        """Return stream object."""
        return None

    @property
    def mem(self):
        """Return memory object."""
        return None

    @property
    def url(self):
        """Return url to file."""
        return None

    @property
    def size(self):
        """Length of the linked content in octets."""
        return None

    @property
    def post_data(self):
        raise NotImplementedError

    # Will raise an error if used on invalid object
    @post_data.setter
    def post_data(self, value):
        raise NotImplementedError


[docs]class IOHandler(object): """Base IO handling class that handle multple IO types This class is created with NoneIOHandler that have no data inside. To initialise data you can set the `file`, `url`, `data` or `stream` attribute. If reset one of this attribute old data are lost and replaced by the new one. :param workdir: working directory, to save temporal file objects in. :param mode: ``MODE`` validation mode. `file` : str Filename on the local disk. `url` : str Link to an online resource. `stream` : FileIO A readable object. `data` : object A native python object (integer, string, float, etc) `base64` : str A base 64 encoding of the data. >>> # setting up >>> import os >>> from io import RawIOBase >>> from io import FileIO >>> >>> ioh_file = IOHandler(workdir=tmp) >>> assert isinstance(ioh_file, IOHandler) >>> >>> # Create test file input >>> fileobj = open(os.path.join(tmp, 'myfile.txt'), 'w') >>> fileobj.write('ASDF ASFADSF ASF ASF ASDF ASFASF') >>> fileobj.close() >>> >>> # testing file object on input >>> ioh_file.file = fileobj.name >>> assert ioh_file.file == fileobj.name >>> assert isinstance(ioh_file.stream, RawIOBase) >>> # skipped assert isinstance(ioh_file.memory_object, POSH) >>> >>> # testing stream object on input >>> ioh_stream = IOHandler(workdir=tmp) >>> assert ioh_stream.workdir == tmp >>> ioh_stream.stream = FileIO(fileobj.name,'r') >>> assert open(ioh_stream.file).read() == ioh_file.stream.read() >>> assert isinstance(ioh_stream.stream, RawIOBase) """ def __init__(self, workdir=None, mode=MODE.NONE): self._iohandler = NoneIOHandler(self) # Internal defaults for class and subclass properties. self._workdir = None # Set public defaults self.workdir = workdir self.valid_mode = mode # TODO: Clarify intent self.as_reference = False self.inpt = {} self.uuid = None # request identifier self.data_set = False def _check_valid(self): """Validate this input using given validator """ validate = self.validator if validate is not None: _valid = validate(self, self.valid_mode) if not _valid: self.data_set = False raise InvalidParameterValue('Input data not valid using ' 'mode {}'.format(self.valid_mode)) self.data_set = True @property def workdir(self): return self._workdir @workdir.setter def workdir(self, path): """Set working temporary directory for files to be stored in.""" if path is not None: if not os.path.exists(path): os.makedirs(path) self._workdir = path @property def validator(self): """Return the function suitable for validation This method should be overridden by class children :return: validating function """ return emptyvalidator @property def source_type(self): """Return the source type.""" # For backward compatibility only. source_type checks could be replaced by `isinstance`. return getattr(SOURCE_TYPE, self.prop.upper()) def _set_default_value(self, value=None, value_type=None): """Set default value based on input data type.""" value = value or getattr(self, '_default') value_type = value_type or getattr(self, '_default_type') if value: # only set default when a value is optional if self.min_occurs == 0: if value_type == SOURCE_TYPE.DATA: self.data = value elif value_type == SOURCE_TYPE.MEMORY: raise NotImplementedError elif value_type == SOURCE_TYPE.FILE: self.file = value elif value_type == SOURCE_TYPE.STREAM: self.stream = value elif value_type == SOURCE_TYPE.URL: self.url = value else: # when a value is requried the default value will be ignored LOGGER.warning( "The given default value will not be used" " because is is required to provide a value.") def _build_file_name(self, href=''): """Return a file name for the local system.""" url_path = urlparse(href).path or '' file_name = os.path.basename(url_path).strip() or 'input' (prefix, suffix) = os.path.splitext(file_name) suffix = suffix or self.extension if prefix and suffix: file_name = prefix + suffix input_file_name = os.path.join(self.workdir, file_name) # build tempfile in case of duplicates if os.path.exists(input_file_name): input_file_name = tempfile.mkstemp( suffix=suffix, prefix=prefix + '_', dir=self.workdir)[1] return input_file_name @property def extension(self): """Return the file extension for the data format, if set.""" if getattr(self, 'data_format', None): return self.data_format.extension else: return '' def clone(self): """Create copy of yourself """ return deepcopy(self) @property def base64(self): """Return raw data WARNING: may be bytes or str""" return self._iohandler.base64 @property def size(self): """Return object size in bytes. """ return self._iohandler.size @property def file(self): """Return a file name""" return self._iohandler.file @file.setter def file(self, value): self._iohandler = FileHandler(value, self) self._check_valid() @property def data(self): """Return raw data WARNING: may be bytes or str""" return self._iohandler.data def data_as_json(self): # applies json.loads if needed data = self._iohandler.data if data and not isinstance(self._iohandler, DataHandler) and self.extension in ['.geojson', 'json']: data = json.loads(data) self.data = data # switch to a DataHandler return data @data.setter def data(self, value): self._iohandler = DataHandler(value, self) self._check_valid() @property def stream(self): """Return stream of data WARNING: may be FileIO or StringIO""" return self._iohandler.stream @stream.setter def stream(self, value): self._iohandler = StreamHandler(value, self) self._check_valid() @property def url(self): """Return the url of data""" return self._iohandler.url @url.setter def url(self, value): self._iohandler = UrlHandler(value, self) self._check_valid() # FIXME: post_data is only related to url, this should be initialize with url setter @property def post_data(self): return self._iohandler.post_data # Will raise an arror if used on invalid object @post_data.setter def post_data(self, value): self._iohandler.post_data = value @property def prop(self): return self._iohandler.prop
class FileHandler(NoneIOHandler): prop = 'file' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._data = None self._stream = None self._file = os.path.abspath(value) @property def file(self): """Return filename.""" return self._file @property def data(self): """Read file and return content.""" if self._data is None: openmode = self._openmode(self._ref()) kwargs = {} if 'b' in openmode else {'encoding': 'utf8'} with open(self.file, mode=openmode, **kwargs) as fh: self._data = fh.read() return self._data @property def base64(self): """Return base64 encoding of data.""" data = self.data.encode() if not isinstance(self.data, bytes) else self.data return base64.b64encode(data) @property def stream(self): """Return stream object.""" from io import FileIO if self._stream and not self._stream.closed: self._stream.close() self._stream = FileIO(self.file, mode='r', closefd=True) return self._stream @property def url(self): """Return url to file.""" result = PurePath(self.file).as_uri() return result @property def size(self): """Length of the linked content in octets.""" return os.stat(self.file).st_size def _openmode(self, base, data=None): openmode = 'r' # in Python 3 we need to open binary files in binary mode. checked = False if hasattr(base, 'data_format'): if base.data_format.encoding == 'base64': # binary, when the data is to be encoded to base64 openmode += 'b' checked = True elif 'text/' in base.data_format.mime_type: # not binary, when mime_type is 'text/' checked = True # when we can't guess it from the mime_type, we need to check the file. # mimetypes like application/xml and application/json are text files too. if not checked and not _is_textfile(self.file): openmode += 'b' return openmode class DataHandler(FileHandler): prop = 'data' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._stream = None self._data = value def _openmode(self, data=None): openmode = 'w' if isinstance(data, bytes): # on Python 3 open the file in binary mode if the source is # bytes, which happens when the data was base64-decoded openmode += 'b' return openmode @property def data(self): """Return data.""" return self._data @property def file(self): """Return file name storing the data. Requesting the file attributes writes the data to a temporary file on disk. """ if self._file is None: self._file = self._ref()._build_file_name() openmode = self._openmode(self.data) kwargs = {} if 'b' in openmode else {'encoding': 'utf8'} with open(self._file, openmode, **kwargs) as fh: if isinstance(self.data, (bytes, str)): fh.write(self.data) else: json.dump(self.data, fh) return self._file @property def stream(self): """Return a stream representation of the data.""" if isinstance(self.data, bytes): return BytesIO(self.data) else: return StringIO(str(self.data)) class StreamHandler(DataHandler): prop = 'stream' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._data = None self._stream = value @property def stream(self): """Return the stream.""" return self._stream @property def data(self): """Return the data from the stream.""" if self._data is None: self._data = self.stream.read() return self._data class UrlHandler(FileHandler): prop = 'url' def __init__(self, value, ref): self._ref = weakref.ref(ref) self._file = None self._data = None self._stream = None self._url = value self._post_data = None @property def url(self): """Return the URL.""" return self._url @property def file(self): """Downloads URL and return file pointer. Checks if size is allowed before download. """ if self._file is not None: return self._file self._file = self._ref()._build_file_name(href=self.url) max_byte_size = self.max_size() # Create request try: reference_file = self._openurl(self.url, self.post_data) data_size = reference_file.headers.get('Content-Length', 0) except Exception as e: raise NoApplicableCode('File reference error: {}'.format(e)) error_message = 'File size for input "{}" exceeded. Maximum allowed: {}'.format( self._ref().inpt.get('identifier', '?'), humanize.naturalsize(max_byte_size)) if int(max_byte_size) > 0: if int(data_size) > int(max_byte_size): raise FileSizeExceeded(error_message) try: with open(self._file, 'wb') as f: data_size = 0 for chunk in reference_file.iter_content(chunk_size=1024): data_size += len(chunk) if int(max_byte_size) > 0: if int(data_size) > int(max_byte_size): raise FileSizeExceeded(error_message) f.write(chunk) except FileSizeExceeded: raise except Exception as e: raise NoApplicableCode(e) return self._file @property def post_data(self): return self._post_data @post_data.setter def post_data(self, value): self._post_data = value @property def size(self): """Get content-length of URL without download""" req = self._openurl(self.url) if req.ok: size = int(req.headers.get('content-length', '0')) else: size = 0 return size @staticmethod def _openurl(href, data=None): """Open given href. """ LOGGER.debug('Fetching URL {}'.format(href)) if data is not None: req = requests.post(url=href, data=data, stream=True) else: req = requests.get(url=href, stream=True) return req @staticmethod def max_size(): """Calculates maximal size for input file based on configuration and units. :return: maximum file size in bytes """ ms = config.get_config_value('server', 'maxsingleinputsize') byte_size = config.get_size_mb(ms) * 1024**2 return byte_size class SimpleHandler(IOHandler): """Data handler for Literal In- and Outputs >>> class Int_type(object): ... @staticmethod ... def convert(value): return int(value) >>> >>> class MyValidator(object): ... @staticmethod ... def validate(inpt): return 0 < inpt.data < 3 >>> >>> inpt = SimpleHandler(data_type = Int_type) >>> inpt.validator = MyValidator >>> >>> inpt.data = 1 >>> inpt.validator.validate(inpt) True >>> inpt.data = 5 >>> inpt.validator.validate(inpt) False """ def __init__(self, workdir=None, data_type=None, mode=MODE.NONE): IOHandler.__init__(self, workdir=workdir, mode=mode) if data_type not in LITERAL_DATA_TYPES: raise ValueError('data_type {} not in {}'.format(data_type, LITERAL_DATA_TYPES)) self.data_type = data_type @IOHandler.data.setter def data(self, value): """Set data value. Inputs are converted into target format. """ if self.data_type and value is not None: value = convert(self.data_type, value) IOHandler.data.fset(self, value) class BasicIO: """Basic Input/Output class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, min_occurs=1, max_occurs=1, metadata=[], translations=None): self.identifier = identifier self.title = title self.abstract = abstract self.keywords = keywords self.min_occurs = int(min_occurs) if min_occurs is not None else 0 self.max_occurs = int(max_occurs) if max_occurs is not None else None self.metadata = metadata self.translations = lower_case_dict(translations) class BasicLiteral: """Basic literal Input/Output class """ def __init__(self, data_type="integer", uoms=None): assert data_type in LITERAL_DATA_TYPES self.data_type = data_type # list of uoms self.uoms = [] # current uom self._uom = None # add all uoms (upcasting to UOM) if uoms is not None: for uom in uoms: if not isinstance(uom, UOM): uom = UOM(uom) self.uoms.append(uom) if self.uoms: # default/current uom self.uom = self.uoms[0] @property def uom(self): return self._uom @uom.setter def uom(self, uom): if uom is not None: self._uom = uom class BasicComplex(object): """Basic complex input/output class """ def __init__(self, data_format=None, supported_formats=None): self._data_format = data_format self._supported_formats = () if supported_formats: self.supported_formats = supported_formats if data_format: self.data_format = data_format elif self.supported_formats: # not an empty list, set the default/current format to the first self.data_format = supported_formats[0] def get_format(self, mime_type): """ :param mime_type: given mimetype :return: Format """ for frmt in self.supported_formats: if frmt.mime_type == mime_type: return frmt else: return None @property def validator(self): """Return the proper validator for given data_format """ return None if self.data_format is None else self.data_format.validate @property def supported_formats(self): return self._supported_formats @supported_formats.setter def supported_formats(self, supported_formats): """Setter of supported formats """ def set_format_validator(supported_format): if not supported_format.validate or \ supported_format.validate == emptyvalidator: supported_format.validate =\ get_validator(supported_format.mime_type) return supported_format self._supported_formats = tuple(map(set_format_validator, supported_formats)) @property def data_format(self): return self._data_format @data_format.setter def data_format(self, data_format): """self data_format setter """ if self._is_supported(data_format): self._data_format = data_format if not data_format.validate or data_format.validate == emptyvalidator: data_format.validate = get_validator(data_format.mime_type) else: raise InvalidParameterValue("Requested format {}, {}, {} not supported".format( data_format.mime_type, data_format.encoding, data_format.schema), 'mimeType') def _is_supported(self, data_format): if self.supported_formats: for frmt in self.supported_formats: if frmt.same_as(data_format): return True return False class BasicBoundingBox(object): """Basic BoundingBox input/output class """ def __init__(self, crss=None, dimensions=2): self._data = None self.crss = crss or ['epsg:4326'] self.crs = self.crss[0] self.dimensions = dimensions @property def data(self): return self._data @data.setter def data(self, value): if isinstance(value, list): self._data = [float(number) for number in value] elif isinstance(value, str): self._data = [float(number) for number in value.split(',')[:4]] else: self._data = None @property def ll(self): if self.data: return self.data[:2] return [] @property def ur(self): if self.data: return self.data[2:] return [] class LiteralInput(BasicIO, BasicLiteral, SimpleHandler): """LiteralInput input abstract class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, data_type="integer", workdir=None, allowed_values=None, uoms=None, mode=MODE.NONE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) BasicLiteral.__init__(self, data_type, uoms) SimpleHandler.__init__(self, workdir, data_type, mode=mode) if default_type != SOURCE_TYPE.DATA: raise InvalidParameterValue("Source types other than data are not supported.") self.any_value = False self.values_reference = None self.allowed_values = [] if allowed_values: if not isinstance(allowed_values, (tuple, list)): allowed_values = [allowed_values] self.any_value = any(is_anyvalue(a) for a in allowed_values) for value in allowed_values: if is_values_reference(value): self.values_reference = value break self.allowed_values = make_allowedvalues(allowed_values) self._default = default self._default_type = default_type if default is not None: self.data = default @property def validator(self): """Get validator for any value as well as allowed_values :rtype: function """ if self.any_value: return validate_anyvalue elif self.values_reference: return validate_values_reference elif self.allowed_values: return validate_allowed_values else: return validate_value class LiteralOutput(BasicIO, BasicLiteral, SimpleHandler): """Basic LiteralOutput class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, data_type=None, workdir=None, uoms=None, validate=None, mode=MODE.NONE, translations=None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) BasicLiteral.__init__(self, data_type, uoms) SimpleHandler.__init__(self, workdir=None, data_type=data_type, mode=mode) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): self._storage = storage @property def validator(self): """Get validator for any value as well as allowed_values """ return validate_anyvalue class BBoxInput(BasicIO, BasicBoundingBox, IOHandler): """Basic Bounding box input abstract class """ def __init__(self, identifier, title=None, abstract=None, keywords=[], crss=None, dimensions=None, workdir=None, mode=MODE.SIMPLE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) BasicBoundingBox.__init__(self, crss, dimensions) IOHandler.__init__(self, workdir=workdir, mode=mode) if default_type != SOURCE_TYPE.DATA: raise InvalidParameterValue("Source types other than data are not supported.") self._default = default self._default_type = default_type self._set_default_value(default, default_type) class BBoxOutput(BasicIO, BasicBoundingBox, IOHandler): """Basic BoundingBox output class """ def __init__(self, identifier, title=None, abstract=None, keywords=None, crss=None, dimensions=None, workdir=None, mode=MODE.NONE, translations=None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) BasicBoundingBox.__init__(self, crss, dimensions) IOHandler.__init__(self, workdir=workdir, mode=mode) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): self._storage = storage class ComplexInput(BasicIO, BasicComplex, IOHandler): """Complex input abstract class >>> ci = ComplexInput() >>> ci.validator = 1 >>> ci.validator 1 """ def __init__(self, identifier, title=None, abstract=None, keywords=None, workdir=None, data_format=None, supported_formats=None, mode=MODE.NONE, min_occurs=1, max_occurs=1, metadata=[], default=None, default_type=SOURCE_TYPE.DATA, translations=None): BasicIO.__init__(self, identifier=identifier, title=title, abstract=abstract, keywords=keywords, min_occurs=min_occurs, max_occurs=max_occurs, metadata=metadata, translations=translations, ) IOHandler.__init__(self, workdir=workdir, mode=mode) BasicComplex.__init__(self, data_format, supported_formats) self._default = default self._default_type = default_type def file_handler(self, inpt): """<wps:Reference /> handler. Used when href is a file url.""" # check if file url is allowed self._validate_file_input(href=inpt.get('href')) # save the file reference input in workdir tmp_file = self._build_file_name(href=inpt.get('href')) try: inpt_file = urlparse(inpt.get('href')).path inpt_file = os.path.abspath(inpt_file) os.symlink(inpt_file, tmp_file) LOGGER.debug("Linked input file {} to {}.".format(inpt_file, tmp_file)) except Exception: # TODO: handle os.symlink on windows # raise NoApplicableCode("Could not link file reference: {}".format(e)) LOGGER.warning("Could not link file reference") shutil.copy2(inpt_file, tmp_file) return tmp_file def url_handler(self, inpt): # That could possibly go into the data property... if inpt.get('method') == 'POST': if 'body' in inpt: self.post_data = inpt.get('body') elif 'bodyreference' in inpt: self.post_data = requests.get(url=inpt.get('bodyreference')).text else: raise AttributeError("Missing post data content.") return inpt.get('href') def process(self, inpt): """Subclass with the appropriate handler given the data input.""" href = inpt.get('href', None) self.inpt = inpt if href: if urlparse(href).scheme == 'file': self.file = self.file_handler(inpt) else: # No file download occurs here. The file content will # only be retrieved when the file property is accessed. self.url = self.url_handler(inpt) else: self.data = inpt.get('data') @staticmethod def _validate_file_input(href): href = href or '' parsed_url = urlparse(href) if parsed_url.scheme != 'file': raise FileURLNotSupported('Invalid URL scheme') file_path = parsed_url.path if not file_path: raise FileURLNotSupported('Invalid URL path') file_path = os.path.abspath(file_path) # build allowed paths list inputpaths = config.get_config_value('server', 'allowedinputpaths') allowed_paths = [os.path.abspath(p.strip()) for p in inputpaths.split(os.pathsep) if p.strip()] for allowed_path in allowed_paths: if file_path.startswith(allowed_path): LOGGER.debug("Accepted file url as input.") return raise FileURLNotSupported() class ComplexOutput(BasicIO, BasicComplex, IOHandler): """Complex output abstract class >>> # temporary configuration >>> import ConfigParser >>> from pywps.storage import * >>> config = ConfigParser.RawConfigParser() >>> config.add_section('FileStorage') >>> config.set('FileStorage', 'target', './') >>> config.add_section('server') >>> config.set('server', 'outputurl', 'http://foo/bar/filestorage') >>> >>> # create temporary file >>> tiff_file = open('file.tiff', 'w') >>> tiff_file.write("AA") >>> tiff_file.close() >>> >>> co = ComplexOutput() >>> co.file ='file.tiff' >>> fs = FileStorage(config) >>> co.storage = fs >>> >>> url = co.url # get url, data are stored >>> >>> co.stream.read() # get data - nothing is stored 'AA' """ def __init__(self, identifier, title=None, abstract=None, keywords=None, workdir=None, data_format=None, supported_formats: Supported_Formats = None, mode=MODE.NONE, translations: Translations = None): BasicIO.__init__(self, identifier, title, abstract, keywords, translations=translations) IOHandler.__init__(self, workdir=workdir, mode=mode) BasicComplex.__init__(self, data_format, supported_formats) self._storage = None @property def storage(self): return self._storage @storage.setter def storage(self, storage): # don't set storage twice if self._storage is None: self._storage = storage # TODO: refactor ? def get_url(self): """Return URL pointing to data """ # TODO: it is not obvious that storing happens here (_, _, url) = self.storage.store(self) # url = self.storage.url(self) return url