Source code for datalad.distribution.siblings

# emacs: -*- mode: python; py-indent-offset: 4; tab-width: 4; indent-tabs-mode: nil -*-
# ex: set sts=4 ts=4 sw=4 noet:
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
#
#   See COPYING file distributed along with the datalad package for the
#   copyright and license terms.
#
# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
"""Plumbing command for managing sibling configuration"""

__docformat__ = 'restructuredtext'


import logging

import os
import os.path as op

from urllib.parse import urlparse

from datalad.interface.base import Interface
from datalad.interface.utils import eval_results
from datalad.interface.base import build_doc
from datalad.interface.results import get_status_dict
from datalad.support.annexrepo import AnnexRepo
from datalad.support.constraints import (
    EnsureStr,
    EnsureChoice,
    EnsureNone,
    EnsureBool,
)
from datalad.support.param import Parameter
from datalad.support.exceptions import (
    CommandError,
    InsufficientArgumentsError,
    AccessDeniedError,
    AccessFailedError,
    RemoteNotAvailableError,
    DownloadError,
)
from datalad.support.network import (
    RI,
    PathRI,
    URL,
)
from datalad.support.gitrepo import GitRepo
from datalad.interface.common_opts import (
    recursion_flag,
    recursion_limit,
    as_common_datasrc,
    publish_depends,
    publish_by_default,
    annex_wanted_opt,
    annex_required_opt,
    annex_group_opt,
    annex_groupwanted_opt,
    inherit_opt,
    location_description,
)
from datalad.downloaders.credentials import UserPassword
from datalad.distribution.dataset import (
    require_dataset,
    Dataset,
)
from datalad.distribution.update import Update
from datalad.utils import (
    ensure_list,
    slash_join,
    Path,
)
from datalad.dochelpers import exc_str

from .dataset import (
    EnsureDataset,
    datasetmethod,
)
import datalad.support.ansi_colors as ac


lgr = logging.getLogger('datalad.distribution.siblings')


def _mangle_urls(url, ds_name):
    if not url:
        return url
    return url.replace("%NAME", ds_name.replace("/", "-"))


@build_doc
class Siblings(Interface):
    """Manage sibling configuration

    This command offers four different actions: 'query', 'add', 'remove',
    'configure', 'enable'. 'query' is the default action and can be used to obtain
    information about (all) known siblings. 'add' and 'configure' are highly
    similar actions, the only difference being that adding a sibling
    with a name that is already registered will fail, whereas
    re-configuring a (different) sibling under a known name will not
    be considered an error. 'enable' can be used to complete access
    configuration for non-Git sibling (aka git-annex special remotes).
    Lastly, the 'remove' action allows for the
    removal (or de-configuration) of a registered sibling.

    For each sibling (added, configured, or queried) all known sibling
    properties are reported. This includes:

    "name"
        Name of the sibling

    "path"
        Absolute path of the dataset

    "url"
        For regular siblings at minimum a "fetch" URL, possibly also a
        "pushurl"

    Additionally, any further configuration will also be reported using
    a key that matches that in the Git configuration.

    By default, sibling information is rendered as one line per sibling
    following this scheme::

      <dataset_path>: <sibling_name>(<+|->) [<access_specification]

    where the `+` and `-` labels indicate the presence or absence of a
    remote data annex at a particular remote, and `access_specification`
    contains either a URL and/or a type label for the sibling.
    """
    # make the custom renderer the default, path reporting isn't the top
    # priority here
    result_renderer = 'tailored'

    _params_ = dict(
        dataset=Parameter(
            args=("-d", "--dataset"),
            doc="""specify the dataset to configure.  If
            no dataset is given, an attempt is made to identify the dataset
            based on the input and/or the current working directory""",
            constraints=EnsureDataset() | EnsureNone()),
        name=Parameter(
            args=('-s', '--name',),
            metavar='NAME',
            doc="""name of the sibling. For addition with path "URLs" and
            sibling removal this option is mandatory, otherwise the hostname
            part of a given URL is used as a default. This option can be used
            to limit 'query' to a specific sibling.""",
            constraints=EnsureStr() | EnsureNone()),
        action=Parameter(
            args=('action',),
            nargs='?',
            metavar='ACTION',
            doc="""command action selection (see general documentation)""",
            constraints=EnsureChoice('query', 'add', 'remove', 'configure', 'enable') | EnsureNone()),
        url=Parameter(
            args=('--url',),
            doc="""the URL of or path to the dataset sibling named by
                `name`. For recursive operation it is required that
                a template string for building subdataset sibling URLs
                is given.\n List of currently available placeholders:\n
                %%NAME\tthe name of the dataset, where slashes are replaced by
                dashes.""",
            constraints=EnsureStr() | EnsureNone(),
            nargs="?"),
        pushurl=Parameter(
            args=('--pushurl',),
            doc="""in case the `url` cannot be used to publish to the dataset
                sibling, this option specifies a URL to be used instead.\nIf no
                `url` is given, `pushurl` serves as `url` as well.""",
            constraints=EnsureStr() | EnsureNone()),
        description=location_description,

        ## info options
        # --template/cfgfrom gh-1462 (maybe also for a one-time inherit)
        # --wanted gh-925 (also see below for add_sibling approach)

        fetch=Parameter(
            args=("--fetch",),
            action="store_true",
            doc="""fetch the sibling after configuration"""),
        as_common_datasrc=as_common_datasrc,
        publish_depends=publish_depends,
        publish_by_default=publish_by_default,
        annex_wanted=annex_wanted_opt,
        annex_required=annex_required_opt,
        annex_group=annex_group_opt,
        annex_groupwanted=annex_groupwanted_opt,
        inherit=inherit_opt,
        get_annex_info=Parameter(
            args=("--no-annex-info",),
            dest='get_annex_info',
            action="store_false",
            doc="""Whether to query all information about the annex configurations
            of siblings. Can be disabled if speed is a concern"""),
        recursive=recursion_flag,
        recursion_limit=recursion_limit)

    @staticmethod
    @datasetmethod(name='siblings')
    @eval_results
    def __call__(
            action='query',
            dataset=None,
            name=None,
            url=None,
            pushurl=None,
            description=None,
            # TODO consider true, for now like add_sibling
            fetch=False,
            as_common_datasrc=None,
            publish_depends=None,
            publish_by_default=None,
            annex_wanted=None,
            annex_required=None,
            annex_group=None,
            annex_groupwanted=None,
            inherit=False,
            get_annex_info=True,
            recursive=False,
            recursion_limit=None):

        # TODO: Detect malformed URL and fail?
        # XXX possibly fail if fetch is False and as_common_datasrc

        if annex_groupwanted and not annex_group:
            raise InsufficientArgumentsError(
                "To set groupwanted, you need to provide annex_group option")

        # TODO catch invalid action specified
        action_worker_map = {
            'query': _query_remotes,
            'add': _add_remote,
            'configure': _configure_remote,
            'remove': _remove_remote,
            'enable': _enable_remote,
        }
        # all worker strictly operate on a single dataset
        # anything that deals with hierarchies and/or dataset
        # relationships in general should be dealt with in here
        # at the top-level and vice versa
        worker = action_worker_map[action]

        dataset = require_dataset(
            dataset, check_installed=False, purpose='configure sibling')
        refds_path = dataset.path

        res_kwargs = dict(refds=refds_path, logger=lgr)

        ds_name = op.basename(dataset.path)

        # do not form single list of datasets (with recursion results) to
        # give fastest possible response, for the precise of a long-all
        # function call
        ds = dataset
        for r in worker(
                # always copy signature to below to avoid bugs!
                ds, name,
                ds.repo.get_remotes(),
                # for top-level dataset there is no layout questions
                _mangle_urls(url, ds_name),
                _mangle_urls(pushurl, ds_name),
                fetch, description,
                as_common_datasrc, publish_depends, publish_by_default,
                annex_wanted, annex_required, annex_group, annex_groupwanted,
                inherit, get_annex_info,
                **res_kwargs):
            yield r
        if not recursive:
            return

        # do we have instructions to register siblings with some alternative
        # layout?
        replicate_local_structure = url and "%NAME" not in url

        subds_pushurl = None
        for subds in dataset.subdatasets(
                fulfilled=True,
                recursive=recursive, recursion_limit=recursion_limit,
                result_xfm='datasets'):
            subds_name = op.relpath(subds.path, start=dataset.path)
            if replicate_local_structure:
                subds_url = slash_join(url, subds_name)
                if pushurl:
                    subds_pushurl = slash_join(pushurl, subds_name)
            else:
                subds_url = \
                    _mangle_urls(url, '/'.join([ds_name, subds_name]))
                subds_pushurl = \
                    _mangle_urls(pushurl, '/'.join([ds_name, subds_name]))
            for r in worker(
                    # always copy signature from above to avoid bugs
                    subds, name,
                    subds.repo.get_remotes(),
                    subds_url,
                    subds_pushurl,
                    fetch,
                    description,
                    as_common_datasrc, publish_depends, publish_by_default,
                    annex_wanted, annex_required, annex_group, annex_groupwanted,
                    inherit, get_annex_info,
                    **res_kwargs):
                yield r

    @staticmethod
    def custom_result_renderer(res, **kwargs):
        from datalad.ui import ui
        # should we attempt to remove an unknown sibling, complain like Git does
        if res['status'] == 'notneeded' and res['action'] == 'remove-sibling':
            ui.message(
                '{warn}: No sibling "{name}" in dataset {path}'.format(
                    warn=ac.color_word('Warning', ac.LOG_LEVEL_COLORS['WARNING']),
                    **res)
            )
            return
        if res['status'] != 'ok' or not res.get('action', '').endswith('-sibling') :
            # logging complained about this already
            return
        path = op.relpath(res['path'],
                       res['refds']) if res.get('refds', None) else res['path']
        got_url = 'url' in res
        spec = '{}{}{}{}'.format(
            res.get('url', ''),
            ' (' if got_url else '',
            res.get('annex-externaltype', 'git'),
            ')' if got_url else '')
        ui.message('{path}: {name}({with_annex}) [{spec}]'.format(
            **dict(
                res,
                path=path,
                # TODO report '+' for special remotes
                with_annex='+' if 'annex-uuid' in res \
                    else ('-' if res.get('annex-ignore', None) else '?'),
                spec=spec)))


# always copy signature from above to avoid bugs
def _add_remote(
        ds, name, known_remotes, url, pushurl, fetch, description,
        as_common_datasrc, publish_depends, publish_by_default,
        annex_wanted, annex_required, annex_group, annex_groupwanted,
        inherit, get_annex_info,
        **res_kwargs):
    # TODO: allow for no url if 'inherit' and deduce from the super ds
    #       create-sibling already does it -- generalize/use
    #  Actually we could even inherit/deduce name from the super by checking
    #  which remote it is actively tracking in current branch... but may be
    #  would be too much magic

    # it seems that the only difference is that `add` should fail if a remote
    # already exists
    if (url is None and pushurl is None):
        raise InsufficientArgumentsError(
            """insufficient information to add a sibling
            (needs at least a dataset, and any URL).""")
    if url is None:
        url = pushurl

    if not name:
        urlri = RI(url)
        # use the hostname as default remote name
        try:
            name = urlri.hostname
        except AttributeError:
            raise InsufficientArgumentsError(
                "cannot derive a default remote name from '{}', "
                "please specify a name.".format(url))
        lgr.debug(
            "No sibling name given, use URL hostname '%s' as sibling name",
            name)

    if not name:
        raise InsufficientArgumentsError("no sibling name given")
    if name in known_remotes:
        yield get_status_dict(
            action='add-sibling',
            status='error',
            path=ds.path,
            type='sibling',
            name=name,
            message=("sibling is already known: %s, use `configure` instead?", name),
            **res_kwargs)
        return
    if isinstance(RI(url), PathRI):
        # make sure any path URL is stored in POSIX conventions for consistency
        # with git's behavior (e.g. origin configured by clone)
        url = Path(url).as_posix()
    # this remote is fresh: make it known
    # just minimalistic name and URL, the rest is coming from `configure`
    ds.repo.add_remote(name, url)
    known_remotes.append(name)
    # always copy signature from above to avoid bugs
    for r in _configure_remote(
            ds, name, known_remotes, url, pushurl, fetch, description,
            as_common_datasrc, publish_depends, publish_by_default,
            annex_wanted, annex_required, annex_group, annex_groupwanted,
            inherit, get_annex_info,
            **res_kwargs):
        if r['action'] == 'configure-sibling':
            r['action'] = 'add-sibling'
        yield r


# always copy signature from above to avoid bugs
def _configure_remote(
        ds, name, known_remotes, url, pushurl, fetch, description,
        as_common_datasrc, publish_depends, publish_by_default,
        annex_wanted, annex_required, annex_group, annex_groupwanted,
        inherit, get_annex_info,
        **res_kwargs):
    result_props = dict(
        action='configure-sibling',
        path=ds.path,
        type='sibling',
        name=name,
        **res_kwargs)
    if name is None:
        result_props['status'] = 'error'
        result_props['message'] = 'need sibling `name` for configuration'
        yield result_props
        return

    if name != 'here':
        # do all configure steps that are not meaningful for the 'here' sibling
        # AKA the local repo
        if name not in known_remotes and url:
            # this remote is fresh: make it known
            # just minimalistic name and URL, the rest is coming from `configure`
            ds.repo.add_remote(name, url)
            known_remotes.append(name)
        elif url:
            # not new, override URl if given
            ds.repo.set_remote_url(name, url)

        # make sure we have a configured fetch expression at this point
        fetchvar = 'remote.{}.fetch'.format(name)
        if fetchvar not in ds.repo.config:
            # place default fetch refspec in config
            # same as `git remote add` would have added
            ds.repo.config.add(
                fetchvar,
                '+refs/heads/*:refs/remotes/{}/*'.format(name),
                where='local')

        if pushurl:
            ds.repo.set_remote_url(name, pushurl, push=True)

        if publish_depends:
            # Check if all `deps` remotes are known to the `repo`
            unknown_deps = set(ensure_list(publish_depends)).difference(
                known_remotes)
            if unknown_deps:
                result_props['status'] = 'error'
                result_props['message'] = (
                    'unknown sibling(s) specified as publication dependency: %s',
                    unknown_deps)
                yield result_props
                return

        # define config var name for potential publication dependencies
        depvar = 'remote.{}.datalad-publish-depends'.format(name)
        # and default pushes
        dfltvar = "remote.{}.push".format(name)

        if fetch:
            # fetch the remote so we are up to date
            for r in Update.__call__(
                    dataset=ds.path,
                    sibling=name,
                    merge=False,
                    recursive=False,
                    on_failure='ignore',
                    return_type='generator',
                    result_xfm=None):
                # fixup refds
                r.update(res_kwargs)
                yield r

        delayed_super = _DelayedSuper(ds.repo)
        if inherit and delayed_super.super is not None:
            # Adjust variables which we should inherit
            publish_depends = _inherit_config_var(
                delayed_super, depvar, publish_depends)
            publish_by_default = _inherit_config_var(
                delayed_super, dfltvar, publish_by_default)
            # Copy relevant annex settings for the sibling
            # makes sense only if current AND super are annexes, so it is
            # kinda a boomer, since then forbids having a super a pure git
            if isinstance(ds.repo, AnnexRepo) and \
                    isinstance(delayed_super.repo, AnnexRepo) and \
                    name in delayed_super.repo.get_remotes():
                if annex_wanted is None:
                    annex_wanted = _inherit_annex_var(
                        delayed_super, name, 'wanted')
                if annex_required is None:
                    annex_required = _inherit_annex_var(
                        delayed_super, name, 'required')
                if annex_group is None:
                    # I think it might be worth inheritting group regardless what
                    # value is
                    #if annex_wanted in {'groupwanted', 'standard'}:
                    annex_group = _inherit_annex_var(
                        delayed_super, name, 'group'
                    )
                if annex_wanted == 'groupwanted' and annex_groupwanted is None:
                    # we better have a value for the expression for that group
                    annex_groupwanted = _inherit_annex_var(
                        delayed_super, name, 'groupwanted'
                    )

        if publish_depends:
            if depvar in ds.config:
                # config vars are incremental, so make sure we start from
                # scratch
                ds.config.unset(depvar, where='local', reload=False)
            for d in ensure_list(publish_depends):
                lgr.info(
                    'Configure additional publication dependency on "%s"',
                    d)
                ds.config.add(depvar, d, where='local', reload=False)
            ds.config.reload()

        if publish_by_default:
            if dfltvar in ds.config:
                ds.config.unset(dfltvar, where='local', reload=False)
            for refspec in ensure_list(publish_by_default):
                lgr.info(
                    'Configure additional default publication refspec "%s"',
                    refspec)
                ds.config.add(dfltvar, refspec, 'local')
            ds.config.reload()

        assert isinstance(ds.repo, GitRepo)  # just against silly code
        if isinstance(ds.repo, AnnexRepo):
            # we need to check if added sibling an annex, and try to enable it
            # another part of the fix for #463 and #432
            try:
                exc = None
                if not ds.config.obtain(
                        'remote.{}.annex-ignore'.format(name),
                        default=False,
                        valtype=EnsureBool(),
                        store=False):
                    ds.repo.enable_remote(name)
            except (CommandError, DownloadError) as exc:
                # TODO yield
                # this is unlikely to ever happen, now done for AnnexRepo
                # instances only
                # Note: CommandError happens with git-annex
                # 6.20180416+gitg86b18966f-1~ndall+1 (prior 6.20180510, from
                # which starts to fail with AccessFailedError) if URL is bogus,
                # so enableremote fails. E.g. as "tested" in test_siblings
                lgr.info(
                    "Could not enable annex remote %s. This is expected if %s "
                    "is a pure Git remote, or happens if it is not accessible.",
                    name, name)
                lgr.debug("Exception was: %s", exc_str(exc))

            if as_common_datasrc:
                ri = RI(url)
                if isinstance(ri, URL) and ri.scheme in ('http', 'https'):
                    # XXX what if there is already a special remote
                    # of this name? Above check for remotes ignores special
                    # remotes. we need to `git annex dead REMOTE` on reconfigure
                    # before we can init a new one
                    # XXX except it is not enough

                    # make special remote of type=git (see #335)
                    ds.repo.call_annex([
                        'initremote',
                        as_common_datasrc,
                        'type=git',
                        'location={}'.format(url),
                        'autoenable=true'])
                else:
                    yield dict(
                        status='impossible',
                        name=name,
                        message='cannot configure as a common data source, '
                                'URL protocol is not http or https',
                        **result_props)
    #
    # place configure steps that also work for 'here' below
    #
    if isinstance(ds.repo, AnnexRepo):
        for prop, var in (('wanted', annex_wanted),
                          ('required', annex_required),
                          ('group', annex_group)):
            if var is not None:
                ds.repo.set_preferred_content(prop, var, '.' if name =='here' else name)
        if annex_groupwanted:
            ds.repo.set_groupwanted(annex_group, annex_groupwanted)

    if description:
        if not isinstance(ds.repo, AnnexRepo):
            result_props['status'] = 'impossible'
            result_props['message'] = 'cannot set description of a plain Git repository'
            yield result_props
            return
        ds.repo.call_annex(['describe', name, description])

    # report all we know at once
    info = list(_query_remotes(ds, name, known_remotes, get_annex_info=get_annex_info))[0]
    info.update(dict(status='ok', **result_props))
    yield info


# always copy signature from above to avoid bugs
def _query_remotes(
        ds, name, known_remotes, url=None, pushurl=None, fetch=None, description=None,
        as_common_datasrc=None, publish_depends=None, publish_by_default=None,
        annex_wanted=None, annex_required=None, annex_group=None, annex_groupwanted=None,
        inherit=None, get_annex_info=True,
        **res_kwargs):
    annex_info = {}
    available_space = None
    if get_annex_info and isinstance(ds.repo, AnnexRepo):
        # pull repo info from annex
        try:
            # need to do in safety net because of gh-1560
            raw_info = ds.repo.repo_info(fast=True)
        except CommandError:
            raw_info = {}
        available_space = raw_info.get('available local disk space', None)
        for trust in ('trusted', 'semitrusted', 'untrusted'):
            ri = raw_info.get('{} repositories'.format(trust), [])
            for r in ri:
                uuid = r.get('uuid', '00000000-0000-0000-0000-00000000000')
                if uuid.startswith('00000000-0000-0000-0000-00000000000'):
                    continue
                ainfo = annex_info.get(uuid, {})
                ainfo['description'] = r.get('description', None)
                annex_info[uuid] = ainfo
    # treat the local repo as any other remote using 'here' as a label
    remotes = [name] if name else ['here'] + known_remotes
    for remote in remotes:
        info = get_status_dict(
            action='query-sibling',
            path=ds.path,
            type='sibling',
            name=remote,
            **res_kwargs)
        if remote != 'here' and remote not in known_remotes:
            info['status'] = 'error'
            info['message'] = 'unknown sibling name'
            yield info
            continue
        # now pull everything we know out of the config
        # simply because it is cheap and we don't have to go through
        # tons of API layers to be able to work with it
        if remote == 'here':
            # special case: this repo
            # aim to provide info using the same keys as for remotes
            # (see below)
            for src, dst in (('annex.uuid', 'annex-uuid'),
                             ('core.bare', 'annex-bare'),
                             ('annex.version', 'annex-version')):
                val = ds.config.get(src, None)
                if val is None:
                    continue
                info[dst] = val
            if available_space is not None:
                info['available_local_disk_space'] = available_space
        else:
            # common case: actual remotes
            for remotecfg in [k for k in ds.config.keys()
                              if k.startswith('remote.{}.'.format(remote))]:
                info[remotecfg[8 + len(remote):]] = ds.config[remotecfg]
        if get_annex_info and info.get('annex-uuid', None):
            ainfo = annex_info.get(info['annex-uuid'], {})
            annex_description = ainfo.get('description', None)
            if annex_description is not None:
                info['annex-description'] = annex_description
        if get_annex_info and isinstance(ds.repo, AnnexRepo):
            if not ds.repo.is_remote_annex_ignored(remote):
                try:
                    for prop in ('wanted', 'required', 'group'):
                        var = ds.repo.get_preferred_content(
                            prop, '.' if remote == 'here' else remote)
                        if var:
                            info['annex-{}'.format(prop)] = var
                    groupwanted = ds.repo.get_groupwanted(remote)
                    if groupwanted:
                        info['annex-groupwanted'] = groupwanted
                except CommandError as exc:
                    if 'cannot determine uuid' in exc.stderr:
                        # not an annex (or no connection), would be marked as
                        #  annex-ignore
                        msg = "Could not detect whether %s carries an annex. " \
                              "If %s is a pure Git remote, this is expected. " %\
                              (remote, remote)
                        ds.repo.config.reload()
                        if ds.repo.is_remote_annex_ignored(remote):
                            msg += "Remote was marked by annex as annex-ignore. " \
                                   "Edit .git/config to reset if you think that was done by mistake due to absent connection etc"
                        lgr.warning(msg)
                        info['annex-ignore'] = True
                    else:
                        raise
            else:
                info['annex-ignore'] = True

        info['status'] = 'ok'
        yield info


def _remove_remote(
        ds, name, known_remotes, url, pushurl, fetch, description,
        as_common_datasrc, publish_depends, publish_by_default,
        annex_wanted, annex_required, annex_group, annex_groupwanted,
        inherit, get_annex_info,
        **res_kwargs):
    if not name:
        # TODO we could do ALL instead, but that sounds dangerous
        raise InsufficientArgumentsError("no sibling name given")
    result_props = dict(
        action='remove-sibling',
        path=ds.path,
        type='sibling',
        name=name,
        **res_kwargs)
    try:
        # failure can happen and is OK
        ds.repo.remove_remote(name)
    except RemoteNotAvailableError as e:
        yield get_status_dict(
            # result-oriented! given remote is absent already
            status='notneeded',
            **result_props)
        return

    yield get_status_dict(
        status='ok',
        **result_props)


# always copy signature from above to avoid bugs
def _enable_remote(
        ds, name, known_remotes, url, pushurl, fetch, description,
        as_common_datasrc, publish_depends, publish_by_default,
        annex_wanted, annex_required, annex_group, annex_groupwanted,
        inherit, get_annex_info,
        **res_kwargs):
    result_props = dict(
        action='enable-sibling',
        path=ds.path,
        type='sibling',
        name=name,
        **res_kwargs)

    if not isinstance(ds.repo, AnnexRepo):
        yield dict(
            result_props,
            status='impossible',
            message='cannot enable sibling of non-annex dataset')
        return

    if name is None:
        yield dict(
            result_props,
            status='error',
            message='require `name` of sibling to enable')
        return

    # get info on special remote
    sp_remotes = {v['name']: dict(v, uuid=k) for k, v in ds.repo.get_special_remotes().items()}
    remote_info = sp_remotes.get(name, None)

    if remote_info is None:
        yield dict(
            result_props,
            status='impossible',
            message=("cannot enable sibling '%s', not known", name))
        return

    env = None
    cred = None
    if remote_info.get('type', None) == 'webdav':
        # a webdav special remote -> we need to supply a username and password
        if not ('WEBDAV_USERNAME' in os.environ and 'WEBDAV_PASSWORD' in os.environ):
            # nothing user-supplied
            # let's consult the credential store
            hostname = urlparse(remote_info.get('url', '')).netloc
            if not hostname:
                yield dict(
                    result_props,
                    status='impossible',
                    message="cannot determine remote host, credential lookup for webdav access is not possible, and not credentials were supplied")
            cred = UserPassword('webdav:{}'.format(hostname))
            if not cred.is_known:
                try:
                    cred.enter_new(
                        instructions="Enter credentials for authentication with WEBDAV server at {}".format(hostname),
                        user=os.environ.get('WEBDAV_USERNAME', None),
                        password=os.environ.get('WEBDAV_PASSWORD', None))
                except KeyboardInterrupt:
                    # user hit Ctrl-C
                    yield dict(
                        result_props,
                        status='impossible',
                        message="credentials are required for sibling access, abort")
                    return
            creds = cred()
            # update the env with the two necessary variable
            # we need to pass a complete env because of #1776
            env = dict(
                os.environ,
                WEBDAV_USERNAME=creds['user'],
                WEBDAV_PASSWORD=creds['password'])

    try:
        ds.repo.enable_remote(name, env=env)
        result_props['status'] = 'ok'
    except AccessDeniedError as e:
        # credentials are wrong, wipe them out
        if cred and cred.is_known:
            cred.delete()
        result_props['status'] = 'error'
        result_props['message'] = str(e)
    except AccessFailedError as e:
        # some kind of connection issue
        result_props['status'] = 'error'
        result_props['message'] = str(e)
    except Exception as e:
        # something unexpected
        raise e

    yield result_props


def _inherit_annex_var(ds, remote, cfgvar):
    if cfgvar == 'groupwanted':
        var = getattr(ds.repo, 'get_%s' % cfgvar)(remote)
    else:
        var = ds.repo.get_preferred_content(cfgvar, remote)
    if var:
        lgr.info("Inherited annex config from %s %s = %s",
                 ds, cfgvar, var)
    return var


def _inherit_config_var(ds, cfgvar, var):
    if var is None:
        var = ds.config.get(cfgvar)
        if var:
            lgr.info(
                'Inherited publish_depends from %s: %s',
                ds, var)
    return var


class _DelayedSuper(object):
    """A helper to delay deduction on super dataset until needed

    But if asked and not found -- would return None for everything
    """

    def __init__(self, repo):
        self._child_dataset = Dataset(repo.path)
        self._super = None
        self._super_tried = False

    def __str__(self):
        return str(self.super)

    @property
    def super(self):
        if not self._super_tried:
            self._super_tried = True
            # here we must analyze current_ds's super, not the super_ds
            self._super = self._child_dataset.get_superdataset()
            if not self._super:
                lgr.warning(
                    "Cannot determine super dataset for %s, thus "
                    "probably nothing would be inherited where desired"
                    % self._child_dataset
                )
        return self._super

    # Lean proxies going through .super
    @property
    def config(self):
        return self.super.config if self.super else None

    @property
    def repo(self):
        return self.super.repo if self.super else None