Repository: datalad/datalad-next

Issue:
Test uncurl remote with chunking

Discussion:

Patch:
diff --git a/datalad_next/annexremote/uncurl.py b/datalad_next/annexremote/uncurl.py
index 557de86..256cb63 100644
--- a/datalad_next/annexremote/uncurl.py
+++ b/datalad_next/annexremote/uncurl.py
@@ -151,17 +151,29 @@ for composing URLs via a template.
 - ``annex_remoteuuid`` - UUID of the special remote (location) used by git-annex
 - ``git_remotename`` - Name of the Git remote for the uncurl special remote
 
+.. note::
+   The URL template must "resolve" to a complete and valid URL. This cannot
+   be verified at configuration time, because even the URL scheme could be a
+   dynamic setting.
 
 Uploading content
 -----------------
 
 The *uncurl* special remote can upload file content or store annex keys
-via supported URL schemes whenever a URL template is define. At minimum,
-storing at ``file://`` and ``ssh://`` URLs is supported. But other URL
+via supported URL schemes whenever a URL template is defined. At minimum,
+storing at ``file://`` and ``ssh://`` URLs are supported. But other URL
 scheme handlers with upload support may be available at a local DataLad
 installation.
 
 
+Deleting content
+----------------
+
+As for uploading, deleting content is only permitted with a configured
+URL template. Moreover, it also depends on the delete operation being
+supported for a particular URL scheme.
+
+
 Configuration overrides
 -----------------------
 
@@ -204,8 +216,8 @@ from datalad.dataset.gitrepo import GitRepo
 
 from datalad_next.exceptions import (
     CapturedException,
-    DownloadError,
-    UrlTargetNotFound,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
 )
 from datalad_next.url_operations.any import AnyUrlOperations
 from datalad_next.utils import ensure_list
@@ -342,9 +354,7 @@ class UncurlRemote(SpecialRemote):
         try:
             urlprops = self.url_handler.sniff(url)
             return True
-        except DownloadError as e:
-            # TODO untested and subject to change due to
-            # https://github.com/datalad/datalad-next/issues/154
+        except UrlOperationsRemoteError as e:
             # leave a trace in the logs
             CapturedException(e)
             return False
@@ -375,27 +385,26 @@ class UncurlRemote(SpecialRemote):
         )
 
     def transfer_store(self, key, filename):
-        if not self.url_tmpl:
-           raise UnsupportedRequest(
-                'Remote cannot store content without a URL template')
-        url = self.get_key_urls(key)
-        # we have a rewriting template, so we expect exactly one URL
-        assert len(url) == 1
-        url = url[0]
-        try:
-            self.url_handler.upload(
-                from_path=Path(filename),
-                to_url=url,
-            )
-        except Exception as e:
-            # we need to raise RemoteError whenever we could not store
-            raise RemoteError from e
+        return self._store_delete(
+            key,
+            partial(self.url_handler.upload, from_path=Path(filename)),
+            'cannot store',
+        )
 
-    #
-    # unsupported (yet)
-    #
     def remove(self, key):
-        raise UnsupportedRequest('"uncurl" remote cannot remove content')
+        try:
+            return self._store_delete(
+                key,
+                # we have to map parameter names to be able to use a common
+                # helper with transfer_store(), because UrlOperations.upload()
+                # needs to get the URL as a second argument, hence we need
+                # to pass parameters as keyword-args
+                lambda to_url: self.url_handler.delete(url=to_url),
+                'refuses to delete',
+            )
+        except UrlOperationsResourceUnknown as e:
+            self.message(
+                'f{key} not found at the remote, skipping', type='debug')
 
     #
     # helpers
@@ -476,14 +485,12 @@ class UncurlRemote(SpecialRemote):
                 handler(url)
                 # we succeeded, no need to try again
                 return True
-            except UrlTargetNotFound:
+            except UrlOperationsResourceUnknown:
                 # general system access worked, but at the key location is nothing
                 # to be found
                 return False
-            except DownloadError as e:
-                # TODO subject to change due to
-                # https://github.com/datalad/datalad-next/issues/154
-                # TODO return False if we can be sure that the remote
+            except UrlOperationsRemoteError as e:
+                # return False only if we could be sure that the remote
                 # system works properly and just the key is not around
                 ce = CapturedException(e)
                 self.message(
@@ -492,6 +499,25 @@ class UncurlRemote(SpecialRemote):
         raise RemoteError(
             f'Failed to {action[0]} {key!r} {action[1]} any of {urls!r}')
 
+    def _store_delete(self, key, handler, action: str):
+        if not self.url_tmpl:
+           raise RemoteError(
+                f'Remote {action} content without a configured URL template')
+        url = self.get_key_urls(key)
+        # we have a rewriting template, so we expect exactly one URL
+        assert len(url) == 1
+        url = url[0]
+        try:
+            handler(to_url=url)
+        except UrlOperationsResourceUnknown:
+            # pass-through, would happen when removing a non-existing key,
+            # which git-annex wants to be a OK thing to happen.
+            # handler in callers
+            raise
+        except Exception as e:
+            # we need to raise RemoteError whenever we could not perform
+            raise RemoteError from e
+
 
 _sniff2checkurl_map = {
     'content-length': 'size',
diff --git a/datalad_next/download.py b/datalad_next/download.py
index fd33bf3..fddf5f8 100644
--- a/datalad_next/download.py
+++ b/datalad_next/download.py
@@ -23,7 +23,7 @@ from datalad_next.interface import (
 )
 from datalad_next.exceptions import (
     CapturedException,
-    DownloadError,
+    UrlOperationsRemoteError,
 )
 from datalad.support.param import Parameter
 from datalad_next.utils import ensure_list
@@ -319,8 +319,8 @@ class Download(Interface):
                     path=dest,
                     exception=ce,
                 )
-                if issubclass(type(e), DownloadError):
-                    res['status_code'] = e.status
+                if issubclass(type(e), UrlOperationsRemoteError):
+                    res['status_code'] = e.status_code
                 yield res
 
 
diff --git a/datalad_next/exceptions.py b/datalad_next/exceptions.py
index 4e99d16..899068e 100644
--- a/datalad_next/exceptions.py
+++ b/datalad_next/exceptions.py
@@ -2,19 +2,15 @@
 
 from datalad.runner import CommandError
 from datalad.support.exceptions import (
-    AccessDeniedError,
-    AccessFailedError,
     CapturedException,
-    DownloadError,
     IncompleteResultsError,
     NoDatasetFound,
-    TargetFileAbsent,
 )
 
-# derive from TargetFileAbsent as the closest equivalent in datalad-core
-class UrlTargetNotFound(TargetFileAbsent):
-    """A connection request succeeded in principle, but target was not found
-
-    Equivalent of an HTTP404 response.
-    """
-    pass
+from datalad_next.url_operations import (
+    UrlOperationsRemoteError,
+    UrlOperationsAuthenticationError,
+    UrlOperationsAuthorizationError,
+    UrlOperationsInteractionError,
+    UrlOperationsResourceUnknown,
+)
diff --git a/datalad_next/patches/siblings.py b/datalad_next/patches/siblings.py
index 82d109a..ba76c86 100644
--- a/datalad_next/patches/siblings.py
+++ b/datalad_next/patches/siblings.py
@@ -2,7 +2,7 @@ import logging
 
 from datalad.distribution import siblings as mod_siblings
 from datalad.support.annexrepo import AnnexRepo
-from datalad_next.exceptions import (
+from datalad.support.exceptions import (
     AccessDeniedError,
     AccessFailedError,
     CapturedException,
diff --git a/datalad_next/url_operations/__init__.py b/datalad_next/url_operations/__init__.py
index 18df31d..859ba81 100644
--- a/datalad_next/url_operations/__init__.py
+++ b/datalad_next/url_operations/__init__.py
@@ -62,17 +62,26 @@ class UrlOperations:
 
         Raises
         ------
-        AccessFailedError
-          This exception is raised on any error, with a summary of the
-          underlying issues as its message. It carry a status code (e.g. HTTP
-          status code) as its `status` property.  Any underlying exception must
-          be linked via the `__cause__` property (e.g. `raise
-          AccessFailedError(...) from ...`).
-        UrlTargetNotFound
-          Implementations that can distinguish a general "connection error"
-          from an absent download target raise `AccessFailedError` for
-          connection errors, and `UrlTargetNotFound` for download targets
-          found absent after a connection was established successfully.
+        UrlOperationsRemoteError
+          This exception is raised on any access-related error on the remote
+          side, with a summary of the underlying issues as its message.
+          It may carry a status code (e.g. HTTP status code) as its
+          ``status_code`` property.
+          Any underlying exception must be linked via the `__cause__`
+          property (e.g. `raise UrlOperationsRemoteError(...) from ...`).
+        UrlOperationsInteractionError
+        UrlOperationsAuthenticationError
+        UrlOperationsAuthorizationError
+        UrlOperationsResourceUnknown
+          Implementations that can distinguish several remote error types
+          beyond indication a general ``UrlOperationsRemoteError``:
+          ``UrlOperationsInteractionError`` general issues in communicating
+          with the remote side; ``UrlOperationsAuthenticationError`` for
+          errors related to (failed) authentication at the remote;
+          ``UrlOperationsAuthorizationError`` for (lack of) authorizating
+          to access a particular resource of perform a particular operation;
+          ``UrlOperationsResourceUnknown`` if the target of an operation
+          does not exist.
         TimeoutError
           If `timeout` is given and the operation does not complete within the
           number of seconds that a specified by `timeout`.
@@ -122,18 +131,26 @@ class UrlOperations:
 
         Raises
         ------
-        DownloadError
-          This exception is raised on any download-related error, with
-          a summary of the underlying issues as its message. It carry
-          a status code (e.g. HTTP status code) as its `status` property.
+        UrlOperationsRemoteError
+          This exception is raised on any deletion-related error on the remote
+          side, with a summary of the underlying issues as its message.
+          It may carry a status code (e.g. HTTP status code) as its
+          ``status_code`` property.
           Any underlying exception must be linked via the `__cause__`
-          property (e.g. `raise DownloadError(...) from ...`).
-        AccessFailedError
-        UrlTargetNotFound
-          Implementations that can distinguish a general "connection error"
-          from an absent download target raise `AccessFailedError` for
-          connection errors, and `UrlTargetNotFound` for download targets
-          found absent after a conenction was established successfully.
+          property (e.g. `raise UrlOperationsRemoteError(...) from ...`).
+        UrlOperationsInteractionError
+        UrlOperationsAuthenticationError
+        UrlOperationsAuthorizationError
+        UrlOperationsResourceUnknown
+          Implementations that can distinguish several remote error types
+          beyond indication a general ``UrlOperationsRemoteError``:
+          ``UrlOperationsInteractionError`` general issues in communicating
+          with the remote side; ``UrlOperationsAuthenticationError`` for
+          errors related to (failed) authentication at the remote;
+          ``UrlOperationsAuthorizationError`` for (lack of) authorizating
+          to access a particular resource of perform a particular operation;
+          ``UrlOperationsResourceUnknown`` if the target of an operation
+          does not exist.
         TimeoutError
           If `timeout` is given and the operation does not complete within the
           number of seconds that a specified by `timeout`.
@@ -186,12 +203,87 @@ class UrlOperations:
         ------
         FileNotFoundError
           If the source file cannot be found.
+        UrlOperationsRemoteError
+          This exception is raised on any deletion-related error on the remote
+          side, with a summary of the underlying issues as its message.
+          It may carry a status code (e.g. HTTP status code) as its
+          ``status_code`` property.
+          Any underlying exception must be linked via the `__cause__`
+          property (e.g. `raise UrlOperationsRemoteError(...) from ...`).
+        UrlOperationsInteractionError
+        UrlOperationsAuthenticationError
+        UrlOperationsAuthorizationError
+        UrlOperationsResourceUnknown
+          Implementations that can distinguish several remote error types
+          beyond indication a general ``UrlOperationsRemoteError``:
+          ``UrlOperationsInteractionError`` general issues in communicating
+          with the remote side; ``UrlOperationsAuthenticationError`` for
+          errors related to (failed) authentication at the remote;
+          ``UrlOperationsAuthorizationError`` for (lack of) authorizating
+          to access a particular resource of perform a particular operation;
+          ``UrlOperationsResourceUnknown`` if the target of an operation
+          does not exist.
         TimeoutError
           If `timeout` is given and the operation does not complete within the
           number of seconds that a specified by `timeout`.
         """
         raise NotImplementedError
 
+    def delete(self,
+               url: str,
+               *,
+               credential: str | None = None,
+               timeout: float | None = None) -> Dict:
+        """Delete a resource identified by a URL
+
+        Parameters
+        ----------
+        url: str
+          Valid URL with any scheme supported by a particular implementation.
+        credential: str, optional
+          The name of a dedicated credential to be used for authentication
+          in order to perform the deletion. Particular implementations may
+          or may not require or support authentication. They also may or
+          may not support automatic credential lookup.
+        timeout: float, optional
+          If given, specifies a timeout in seconds. If the operation is not
+          completed within this time, it will raise a `TimeoutError`-exception.
+          If timeout is None, the operation will never timeout.
+
+        Returns
+        -------
+        dict
+          A mapping of property names to values for the deletion.
+
+        Raises
+        ------
+        UrlOperationsRemoteError
+          This exception is raised on any deletion-related error on the remote
+          side, with a summary of the underlying issues as its message.
+          It may carry a status code (e.g. HTTP status code) as its
+          ``status_code`` property.
+          Any underlying exception must be linked via the `__cause__`
+          property (e.g. `raise UrlOperationsRemoteError(...) from ...`).
+        UrlOperationsInteractionError
+        UrlOperationsAuthenticationError
+        UrlOperationsAuthorizationError
+        UrlOperationsResourceUnknown
+          Implementations that can distinguish several remote error types
+          beyond indication a general ``UrlOperationsRemoteError``:
+          ``UrlOperationsInteractionError`` general issues in communicating
+          with the remote side; ``UrlOperationsAuthenticationError`` for
+          errors related to (failed) authentication at the remote;
+          ``UrlOperationsAuthorizationError`` for (lack of) authorizating
+          to access a particular resource of perform a particular operation;
+          ``UrlOperationsResourceUnknown`` if the target of an operation
+          does not exist.
+        TimeoutError
+          If `timeout` is given and the operation does not complete within the
+          number of seconds that a specified by `timeout`.
+        """
+        raise NotImplementedError
+
+
     def _get_progress_id(self, from_id: str, to_id: str):
         return f'progress_transport_{from_id}_{to_id}'
 
@@ -246,3 +338,61 @@ class UrlOperations:
             return {}
         else:
             return dict(zip(hash_names, [h.hexdigest() for h in hashers]))
+
+#
+# Exceptions to be used by all handlers
+#
+
+class UrlOperationsRemoteError(Exception):
+    def __init__(self, url, message=None, status_code: Any = None):
+        # use base exception feature to store all arguments in a tuple
+        # and have named properties to access them
+        super().__init__(
+            url,
+            message,
+            status_code,
+        )
+
+    @property
+    def url(self):
+        return self.args[0]
+
+    @property
+    def message(self):
+        return self.args[1]
+
+    @property
+    def status_code(self):
+        return self.args[2]
+
+
+class UrlOperationsResourceUnknown(UrlOperationsRemoteError):
+    """A connection request succeeded in principle, but target was not found
+
+    Equivalent of an HTTP404 response.
+    """
+    pass
+
+
+class UrlOperationsInteractionError(UrlOperationsRemoteError):
+    pass
+
+
+class UrlOperationsAuthenticationError(UrlOperationsInteractionError):
+    def __init__(self,
+                 url: str,
+                 credential: dict | None = None,
+                 message: str | None = None,
+                 status_code: Any = None):
+        super().__init__(url, message=message, status_code=status_code)
+        self.credential = credential
+
+
+class UrlOperationsAuthorizationError(UrlOperationsRemoteError):
+    def __init__(self,
+                 url: str,
+                 credential: dict | None = None,
+                 message: str | None = None,
+                 status_code: Any = None):
+        super().__init__(url, message=message, status_code=status_code)
+        self.credential = credential
diff --git a/datalad_next/url_operations/any.py b/datalad_next/url_operations/any.py
index 631cde5..7604ce2 100644
--- a/datalad_next/url_operations/any.py
+++ b/datalad_next/url_operations/any.py
@@ -11,8 +11,6 @@ from urllib import (
     parse,
 )
 
-from datalad_next.exceptions import DownloadError
-
 from .http import HttpUrlOperations
 from .file import FileUrlOperations
 from .ssh import SshUrlOperations
@@ -107,3 +105,12 @@ class AnyUrlOperations(UrlOperations):
         return self._get_handler(to_url).upload(
             from_path, to_url, credential=credential, hash=hash,
             timeout=timeout)
+
+    def delete(self,
+               url: str,
+               *,
+               credential: str | None = None,
+               timeout: float | None = None) -> Dict:
+        """Call `*UrlOperations.delete()` for the respective URL scheme"""
+        return self._get_handler(url).delete(
+            url, credential=credential, timeout=timeout)
diff --git a/datalad_next/url_operations/file.py b/datalad_next/url_operations/file.py
index 186cbb1..da1fd68 100644
--- a/datalad_next/url_operations/file.py
+++ b/datalad_next/url_operations/file.py
@@ -13,9 +13,12 @@ from urllib import (
 )
 
 from datalad_next.consts import COPY_BUFSIZE
-from datalad_next.exceptions import UrlTargetNotFound
 
-from . import UrlOperations
+from . import (
+    UrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
+)
 
 lgr = logging.getLogger('datalad.ext.next.file_url_operations')
 
@@ -44,12 +47,12 @@ class FileUrlOperations(UrlOperations):
         """Gather information on a URL target, without downloading it
 
         See :meth:`datalad_next.url_operations.UrlOperations.sniff`
-        for parameter documentation.
+        for parameter documentation and exception behavior.
 
         Raises
         ------
-        UrlTargetNotFound
-          Raises `UrlTargetNotFound` for download targets found absent.
+        UrlOperationsResourceUnknown
+          For access targets found absent.
         """
         # filter out internals
         return {
@@ -65,7 +68,7 @@ class FileUrlOperations(UrlOperations):
         try:
             size = from_path.stat().st_size
         except FileNotFoundError as e:
-            raise UrlTargetNotFound(url) from e
+            raise UrlOperationsResourceUnknown(url) from e
         return {
             'content-length': size,
             '_path': from_path,
@@ -84,12 +87,12 @@ class FileUrlOperations(UrlOperations):
         """Copy a file:// URL target to a local path
 
         See :meth:`datalad_next.url_operations.UrlOperations.download`
-        for parameter documentation.
+        for parameter documentation and exception behavior.
 
         Raises
         ------
-        UrlTargetNotFound
-          Raises `UrlTargetNotFound` for download targets found absent.
+        UrlOperationsResourceUnknown
+          For download targets found absent.
         """
         dst_fp = None
         try:
@@ -111,10 +114,16 @@ class FileUrlOperations(UrlOperations):
                     progress_label='downloading',
                 ))
                 return props
+        except PermissionError:
+            # would be a local issue, pass-through
+            raise
+        except UrlOperationsResourceUnknown:
+            # would come from sniff(), pass_through
+            raise
         except Exception as e:
             # wrap this into the datalad-standard, but keep the
             # original exception linked
-            raise UrlTargetNotFound(msg=str(e)) from e
+            raise UrlOperationsRemoteError(from_url, message=str(e)) from e
         finally:
             if dst_fp and to_path is not None:
                 dst_fp.close()
@@ -132,7 +141,7 @@ class FileUrlOperations(UrlOperations):
         necessary.
 
         See :meth:`datalad_next.url_operations.UrlOperations.upload`
-        for parameter documentation.
+        for parameter documentation and exception behavior.
 
         Raises
         ------
@@ -165,10 +174,49 @@ class FileUrlOperations(UrlOperations):
                     progress_label='uploading',
                 ))
                 return props
+        except FileNotFoundError as e:
+            raise UrlOperationsResourceUnknown(url) from e
+        except Exception as e:
+            # wrap this into the datalad-standard, but keep the
+            # original exception linked
+            raise UrlOperationsRemoteError(from_url, message=str(e)) from e
         finally:
             if src_fp and from_path is not None:
                 src_fp.close()
 
+    def delete(self,
+               url: str,
+               *,
+               credential: str | None = None,
+               timeout: float | None = None) -> Dict:
+        """Delete the target of a file:// URL
+
+        The target can be a file or a directory. If it is a directory, it has
+        to be empty.
+
+        See :meth:`datalad_next.url_operations.UrlOperations.delete`
+        for parameter documentation and exception behavior.
+
+        Raises
+        ------
+        UrlOperationsResourceUnknown
+          For deletion targets found absent.
+        """
+        path = self._file_url_to_path(url)
+        try:
+            path.unlink(missing_ok=False)
+        except FileNotFoundError as e:
+            raise UrlOperationsResourceUnknown(url) from e
+        except IsADirectoryError:
+            try:
+                path.rmdir()
+            except Exception as e:
+                raise UrlOperationsRemoteError(url, message=str(e)) from e
+        except Exception as e:
+            # wrap this into the datalad-standard, but keep the
+            # original exception linked
+            raise UrlOperationsRemoteError(url, message=str(e)) from e
+
     def _copyfp(self,
                 src_fp: file,
                 dst_fp: file,
diff --git a/datalad_next/url_operations/http.py b/datalad_next/url_operations/http.py
index 4944fd7..b74d15d 100644
--- a/datalad_next/url_operations/http.py
+++ b/datalad_next/url_operations/http.py
@@ -13,14 +13,13 @@ from requests_toolbelt.downloadutils.tee import tee as requests_tee
 import www_authenticate
 
 import datalad
-from datalad_next.exceptions import (
-    AccessFailedError,
-    UrlTargetNotFound,
-    DownloadError,
-)
 
 from datalad_next.requests_auth import DataladAuth
-from . import UrlOperations
+from . import (
+    UrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
+)
 
 lgr = logging.getLogger('datalad.ext.next.http_url_operations')
 
@@ -56,15 +55,12 @@ class HttpUrlOperations(UrlOperations):
         """Gather information on a URL target, without downloading it
 
         See :meth:`datalad_next.url_operations.UrlOperations.sniff`
-        for parameter documentation.
+        for parameter documentation and exception behavior.
 
         Raises
         ------
-        AccessFailedError
-        UrlTargetNotFound
-          Raises `AccessFailedError` for connection errors, and
-          `UrlTargetNotFound` for download targets found absent after a
-          connection was established successfully.
+        UrlOperationsResourceUnknown
+          For access targets found absent.
         """
         auth = DataladAuth(self.cfg, credential=credential)
         with requests.head(
@@ -84,11 +80,12 @@ class HttpUrlOperations(UrlOperations):
                 # original exception linked
                 if e.response.status_code == 404:
                     # special case reporting for a 404
-                    raise UrlTargetNotFound(
-                        url, status=e.response.status_code) from e
+                    raise UrlOperationsResourceUnknown(
+                        url, status_code=e.response.status_code) from e
                 else:
-                    raise AccessFailedError(
-                        msg=str(e), status=e.response.status_code) from e
+                    raise UrlOperationsRemoteError(
+                        url, message=str(e), status_code=e.response.status_code
+                        ) from e
             props = {
                 # standardize on lower-case header keys.
                 # also prefix anything other than 'content-length' to make
@@ -120,15 +117,12 @@ class HttpUrlOperations(UrlOperations):
         """Download via HTTP GET request
 
         See :meth:`datalad_next.url_operations.UrlOperations.download`
-        for parameter documentation.
+        for parameter documentation and exception behavior.
 
         Raises
         ------
-        AccessFailedError
-        UrlTargetNotFound
-          Raises `AccessFailedError` for connection errors, and
-          `UrlTargetNotFound` for download targets found absent after a
-          connection was established successfully.
+        UrlOperationsResourceUnknown
+          For download targets found absent.
         """
         # a new manager per request
         # TODO optimize later to cache credentials per target
@@ -148,11 +142,12 @@ class HttpUrlOperations(UrlOperations):
                 # original exception linked
                 if e.response.status_code == 404:
                     # special case reporting for a 404
-                    raise UrlTargetNotFound(
-                        from_url, status=e.response.status_code) from e
+                    raise UrlOperationsResourceUnknown(
+                        from_url, status_code=e.response.status_code) from e
                 else:
-                    raise AccessFailedError(
-                        msg=str(e), status=e.response.status_code) from e
+                    raise UrlOperationsRemoteError(
+                        from_url, message=str(e), status_code=e.response.status_code
+                        ) from e
 
             download_props = self._stream_download_from_request(
                 r, to_path, hash=hash)
diff --git a/datalad_next/url_operations/ssh.py b/datalad_next/url_operations/ssh.py
index 618227f..7a6ec15 100644
--- a/datalad_next/url_operations/ssh.py
+++ b/datalad_next/url_operations/ssh.py
@@ -31,14 +31,13 @@ from datalad.runner.protocol import GeneratorMixIn
 from datalad.runner.nonasyncrunner import ThreadedRunner
 
 from datalad_next.consts import COPY_BUFSIZE
-from datalad_next.exceptions import (
-    AccessFailedError,
-    CommandError,
-    DownloadError,
-    UrlTargetNotFound,
-)
+from datalad_next.exceptions import CommandError
 
-from . import UrlOperations
+from . import (
+    UrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
+)
 
 lgr = logging.getLogger('datalad.ext.next.ssh_url_operations')
 
@@ -79,15 +78,7 @@ class SshUrlOperations(UrlOperations):
         """Gather information on a URL target, without downloading it
 
         See :meth:`datalad_next.url_operations.UrlOperations.sniff`
-        for parameter documentation.
-
-        Raises
-        ------
-        AccessFailedError
-        UrlTargetNotFound
-          Raises `AccessFailedError` for connection errors, and
-          `UrlTargetNotFound` for download targets found absent after a
-          connection was established successfully.
+        for parameter documentation and exception behavior.
         """
         try:
             props = self._sniff(
@@ -97,9 +88,9 @@ class SshUrlOperations(UrlOperations):
         except CommandError as e:
             if e.code == 244:
                 # this is the special code for a file-not-found
-                raise UrlTargetNotFound(url) from e
+                raise UrlOperationsResourceUnknown(url) from e
             else:
-                raise AccessFailedError(str(e)) from e
+                raise UrlOperationsRemoteError(url, message=str(e)) from e
 
         return {k: v for k, v in props.items() if not k.startswith('_')}
 
@@ -163,15 +154,7 @@ class SshUrlOperations(UrlOperations):
         the file content is sent via `cat` to the SSH client.
 
         See :meth:`datalad_next.url_operations.UrlOperations.download`
-        for parameter documentation.
-
-        Raises
-        ------
-        AccessFailedError
-        UrlTargetNotFound
-          Raises `AccessFailedError` for connection errors, and
-          `UrlTargetNotFound` for download targets found absent after a
-          connection was established successfully.
+        for parameter documentation and exception behavior.
         """
         # this is pretty much shutil.copyfileobj() with the necessary
         # wrapping to perform hashing and progress reporting
@@ -211,11 +194,11 @@ class SshUrlOperations(UrlOperations):
         except CommandError as e:
             if e.code == 244:
                 # this is the special code for a file-not-found
-                raise UrlTargetNotFound(from_url) from e
+                raise UrlOperationsResourceUnknown(from_url) from e
             else:
                 # wrap this into the datalad-standard, but keep the
                 # original exception linked
-                raise AccessFailedError(msg=str(e)) from e
+                raise UrlOperationsRemoteError(from_url, message=str(e)) from e
         finally:
             if dst_fp and to_path is not None:
                 dst_fp.close()
@@ -233,11 +216,7 @@ class SshUrlOperations(UrlOperations):
         It, more or less, runs `ssh <host> 'cat > <path>'`.
 
         See :meth:`datalad_next.url_operations.UrlOperations.upload`
-        for parameter documentation.
-
-        Raises
-        ------
-        ...
+        for parameter documentation and exception behavior.
         """
 
         if from_path is None:
@@ -321,9 +300,9 @@ class SshUrlOperations(UrlOperations):
             tuple(ssh_runner_generator)
         except CommandError as e:
             if e.code == 244:
-                raise UrlTargetNotFound(to_url) from e
+                raise UrlOperationsResourceUnknown(to_url) from e
             else:
-                raise AccessFailedError(str(e)) from e
+                raise UrlOperationsRemoteError(to_url, message=str(e)) from e
         except (TimeoutError, Full):
             ssh_runner_generator.runner.process.kill()
             raise TimeoutError
diff --git a/datalad_next/utils.py b/datalad_next/utils.py
index 46485f7..52bbd0a 100644
--- a/datalad_next/utils.py
+++ b/datalad_next/utils.py
@@ -13,6 +13,7 @@ from datalad.utils import (
     getargspec,
     knows_annex,
     make_tempfile,
+    on_linux,
     on_windows,
     optional_args,
     rmtemp,


Test patch:
diff --git a/datalad_next/annexremote/tests/test_uncurl.py b/datalad_next/annexremote/tests/test_uncurl.py
index 67e90db..1ea095c 100644
--- a/datalad_next/annexremote/tests/test_uncurl.py
+++ b/datalad_next/annexremote/tests/test_uncurl.py
@@ -12,7 +12,7 @@ from datalad_next.tests.utils import (
 from datalad_next.constraints.dataset import EnsureDataset
 from datalad_next.exceptions import (
     CommandError,
-    DownloadError,
+    UrlOperationsRemoteError,
     IncompleteResultsError,
 )
 from datalad_next.url_operations.any import AnyUrlOperations
@@ -20,7 +20,6 @@ from datalad_next.url_operations.any import AnyUrlOperations
 from ..uncurl import (
     RemoteError,
     UncurlRemote,
-    UnsupportedRequest,
 )
 
 
@@ -47,24 +46,33 @@ class NoOpAnnex:
         pass
 
 
-def test_uncurl_remove(tmp_path):
-    # not yet
+def test_uncurl_remove_no_tmpl(tmp_path):
+    # without a template configured we refuse to remove anything
+    # for the simple reason that it may not be clear what is being
+    # removed at all. We could only iterate over all recorded URLs
+    # and wipe out the key from "the internet". This is, however,
+    # a rather unexpected thing to do from a user perspective --
+    # who would expect a single key instance "at the uncurl remote"
+    # to be removed. The best proxy we have for this expectation
+    # is a URL tmpl being configured, pointing to such a single
+    # location
     r = UncurlRemote(NoOpAnnex())
-    with pytest.raises(UnsupportedRequest):
+    with pytest.raises(RemoteError):
         r.remove(None)
 
 
-def test_uncurl_transfer_store():
+def test_uncurl_transfer_store_no_tmpl():
     r = UncurlRemote(NoOpAnnex())
+    r.url_handler = AnyUrlOperations()
     # whenever there is not template configured
-    with pytest.raises(UnsupportedRequest):
-        r.transfer_store(None, None)
+    with pytest.raises(RemoteError):
+        r.transfer_store(None, '')
 
 
 def test_uncurl_checktretrieve():
 
     def handler(url):
-        raise DownloadError()
+        raise UrlOperationsRemoteError(url)
 
     def get_urls(key):
         return 'some'
@@ -72,7 +80,8 @@ def test_uncurl_checktretrieve():
     r = UncurlRemote(NoOpAnnex())
     r.get_key_urls = get_urls
 
-    # we raise the correct RemoteError and not the underlying DownloadError
+    # we raise the correct RemoteError and not the underlying
+    # UrlOperationsRemoteError
     with pytest.raises(RemoteError):
         r._check_retrieve('somekey', handler, ('blow', 'here'))
 
@@ -371,3 +380,52 @@ def test_uncurl_store_via_ssh(tmp_path):
     dsca(['copy', '-t', 'myuncurl', str(testfile)])
     # let remote verify presence
     dsca(['fsck', '-q', '-f', 'myuncurl'])
+
+
+def test_uncurl_remove(tmp_path):
+    testfile = tmp_path / 'testdeposit' / 'testfile1.txt'
+    testfile_content = 'uppytyup!'
+    testfile.parent.mkdir()
+    testfile.write_text(testfile_content)
+    ds = EnsureDataset()(tmp_path / 'ds').ds.create(**res_kwargs)
+    dsca = ds.repo.call_annex
+    # init without URL template
+    dsca(['initremote', 'myuncurl'] + std_initargs)
+    # add the testdeposit by URL
+    target_fname = ds.pathobj / 'target1.txt'
+    dsca(['addurl', '--file', str(target_fname), testfile.as_uri()])
+    # it will not drop without a URL tmpl
+    # see test_uncurl_remove_no_tmpl() for rational
+    with pytest.raises(CommandError):
+        dsca(['drop', '-f', 'myuncurl', str(target_fname)])
+    assert testfile.read_text() == testfile_content
+
+    # now make it possible
+    # use the simplest possible match expression
+    ds.configuration(
+        'set',
+        'remote.myuncurl.uncurl-match=file://(?P<allofit>.*)$',
+        scope='local', **res_kwargs)
+    # and the presence of a tmpl enables deletion
+    ds.configuration(
+        'set', 'remote.myuncurl.uncurl-url=file://{allofit}',
+        scope='local', **res_kwargs)
+    dsca(['drop', '-f', 'myuncurl', str(target_fname)])
+    assert not testfile.exists()
+
+
+# >30s
+def test_uncurl_testremote(tmp_path):
+    "Point git-annex's testremote at uncurl"
+    ds = EnsureDataset()(tmp_path / 'ds').ds.create(**res_kwargs)
+    dsca = ds.repo.call_annex
+    dsca(['initremote', 'myuncurl'] + std_initargs
+         # file://<basepath>/key
+         + [f'url=file://{tmp_path / "remotepath"}/{{annex_key}}'])
+    # Temporarily disable this until
+    # https://github.com/datalad/datalad-dataverse/issues/127
+    # is sorted out. Possibly via
+    # https://git-annex.branchable.com/bugs/testremote_is_not_honoring_--backend
+    if not on_windows:
+        # not running with --fast to also cover key chunking
+        dsca(['testremote', '--quiet', 'myuncurl'])
diff --git a/datalad_next/url_operations/tests/test_any.py b/datalad_next/url_operations/tests/test_any.py
index bd07288..2193998 100644
--- a/datalad_next/url_operations/tests/test_any.py
+++ b/datalad_next/url_operations/tests/test_any.py
@@ -1,7 +1,6 @@
 import pytest
-from datalad_next.exceptions import (
-    AccessFailedError,
-    UrlTargetNotFound,
+from .. import (
+    UrlOperationsResourceUnknown,
 )
 from ..any import AnyUrlOperations
 
@@ -11,7 +10,7 @@ def test_any_url_operations(tmp_path):
     test_url = test_path.as_uri()
     ops = AnyUrlOperations()
     # no target file (yet), precise exception
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.sniff(test_url)
     # now put something at the target location
     test_path.write_text('surprise!')
@@ -28,7 +27,7 @@ def test_any_url_operations(tmp_path):
 
     # remove source and try again
     test_path.unlink()
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.download(test_url, download_path)
 
     # try some obscure URL scheme
diff --git a/datalad_next/url_operations/tests/test_file.py b/datalad_next/url_operations/tests/test_file.py
index a46c9fc..7b03c62 100644
--- a/datalad_next/url_operations/tests/test_file.py
+++ b/datalad_next/url_operations/tests/test_file.py
@@ -2,11 +2,14 @@ import io
 import locale
 import pytest
 import sys
-from datalad_next.exceptions import (
-    AccessFailedError,
-    UrlTargetNotFound,
+
+from datalad_next.utils import on_linux
+
+from ..file import (
+    FileUrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
 )
-from ..file import FileUrlOperations
 
 
 def test_file_url_download(tmp_path):
@@ -14,7 +17,7 @@ def test_file_url_download(tmp_path):
     test_url = test_path.as_uri()
     ops = FileUrlOperations()
     # no target file (yet), precise exception
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.sniff(test_url)
     # now put something at the target location
     test_path.write_text('surprise!')
@@ -31,7 +34,7 @@ def test_file_url_download(tmp_path):
 
     # remove source and try again
     test_path.unlink()
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.download(test_url, download_path)
 
 
@@ -66,3 +69,34 @@ def test_file_url_upload(tmp_path, monkeypatch):
         assert props['content-length'] == len(payload)
 
     # TODO test missing write permissons
+
+def test_file_url_delete(tmp_path):
+    payload = 'payload'
+    test_path = tmp_path / 'subdir' / 'myfile'
+    test_path.parent.mkdir()
+    test_url = test_path.as_uri()
+    ops = FileUrlOperations()
+    # missing file
+    with pytest.raises(UrlOperationsResourceUnknown):
+        ops.delete(test_url)
+
+    # place file
+    test_path.write_text(payload)
+    assert test_path.read_text() == payload
+    # try deleting a non-empty dir
+    with pytest.raises(UrlOperationsRemoteError):
+        ops.delete(test_path.parent.as_uri())
+
+    # file deletion works
+    ops.delete(test_url)
+    assert not test_path.exists()
+
+    # both windows and mac give incomprehensible AccessDenied
+    # errors on appveyor, although the directory is confirmed
+    # to be empty
+    if on_linux:
+        # empty dir deletion works too
+        # confirm it is indeed empty
+        assert not list(test_path.parent.iterdir())
+        ops.delete(test_path.parent.as_uri())
+        assert not test_path.parent.exists()
diff --git a/datalad_next/url_operations/tests/test_http.py b/datalad_next/url_operations/tests/test_http.py
index 2f75c17..25d1208 100644
--- a/datalad_next/url_operations/tests/test_http.py
+++ b/datalad_next/url_operations/tests/test_http.py
@@ -1,11 +1,12 @@
 from pathlib import Path
 import pytest
-from datalad_next.exceptions import (
-    AccessFailedError,
-    UrlTargetNotFound,
-)
+
 from datalad_next.tests.utils import with_credential
-from ..http import HttpUrlOperations
+from ..http import (
+    HttpUrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
+)
 
 
 hbsurl = 'https://httpbin.org'
@@ -25,7 +26,7 @@ def test_http_url_operations(tmp_path):
     assert props['url'] == target_url
     # same again, but credentials are wrong
     target_url = f'{hbsurl}/basic-auth/mike/WRONG'
-    with pytest.raises(AccessFailedError):
+    with pytest.raises(UrlOperationsRemoteError):
         ops.sniff(f'{hbsurl}/redirect-to?url={target_url}')
     # make sure we get the size info
     assert ops.sniff(f'{hbsurl}/bytes/63')['content-length'] == 63
@@ -38,7 +39,7 @@ def test_http_url_operations(tmp_path):
     assert (tmp_path / 'mydownload').read_text() == 'HTTPBIN is awesome'
 
     # 404s
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.sniff(f'{hbsurl}/status/404')
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.download(f'{hbsurl}/status/404', tmp_path / 'dontmatter')
diff --git a/datalad_next/url_operations/tests/test_ssh.py b/datalad_next/url_operations/tests/test_ssh.py
index 00834bd..8931964 100644
--- a/datalad_next/url_operations/tests/test_ssh.py
+++ b/datalad_next/url_operations/tests/test_ssh.py
@@ -2,15 +2,15 @@ import io
 
 import pytest
 
-from datalad_next.exceptions import (
-    AccessFailedError,
-    UrlTargetNotFound,
-)
 from datalad_next.tests.utils import (
     skip_ssh,
     skip_if_on_windows,
 )
-from ..ssh import SshUrlOperations
+from ..ssh import (
+    SshUrlOperations,
+    UrlOperationsRemoteError,
+    UrlOperationsResourceUnknown,
+)
 
 
 # path magic inside the test is posix only
@@ -23,10 +23,10 @@ def test_ssh_url_download(tmp_path, monkeypatch):
     test_url = f'ssh://localhost{test_path}'
     ops = SshUrlOperations()
     # no target file (yet), precise exception
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.sniff(test_url)
     # this is different for a general connection error
-    with pytest.raises(AccessFailedError):
+    with pytest.raises(UrlOperationsRemoteError):
         ops.sniff(f'ssh://localhostnotaround{test_path}')
     # now put something at the target location
     test_path.write_text('surprise!')
@@ -51,10 +51,10 @@ def test_ssh_url_download(tmp_path, monkeypatch):
 
     # remove source and try again
     test_path.unlink()
-    with pytest.raises(UrlTargetNotFound):
+    with pytest.raises(UrlOperationsResourceUnknown):
         ops.download(test_url, download_path)
     # this is different for a general connection error
-    with pytest.raises(AccessFailedError):
+    with pytest.raises(UrlOperationsRemoteError):
         ops.download(f'ssh://localhostnotaround{test_path}', download_path)