Repository: datalad/datalad-next

Issue:
Rename URL operation `sniff()` -> `stat()`
More commonly understood.

Discussion:

Patch:
diff --git a/README.md b/README.md
index ff46261..d7aae95 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ package and its commands.
 - New modular framework for URL operations. This framework directly supports operation
   on `http(s)`, `ssh`, and `file` URLs, and can be extended with custom functionality
   for additional protocols or even interaction with specific individual servers.
-  The basic operations `download`, `upload`, `delete`, and `sniff` are recognized,
+  The basic operations `download`, `upload`, `delete`, and `stat` are recognized,
   and can be implemented. The framework offers uniform progress reporting and
   simultaneous content has computation. This framework is meant to replace and
   extend the downloader/provide framework in the DataLad core package. In contrast
diff --git a/datalad_next/annexremotes/uncurl.py b/datalad_next/annexremotes/uncurl.py
index ead5ea0..f6ecec9 100644
--- a/datalad_next/annexremotes/uncurl.py
+++ b/datalad_next/annexremotes/uncurl.py
@@ -50,7 +50,7 @@ use after having used them successfully::
     password: 
     password (repeat): 
     Enter a name to save the credential
-    (sniffing http://httpbin.org/basic-auth/myuser/mypassword) securely for future
+    (for accessing http://httpbin.org/basic-auth/myuser/mypassword) securely for future
     re-use, or 'skip' to not save the credential
     name: httpbin-dummy
 
@@ -347,7 +347,7 @@ class UncurlRemote(SpecialRemote):
             # otherwise go ahead with the orginal URL. the template might
             # just be here to aid structured uploads
         try:
-            urlprops = self.url_handler.sniff(url)
+            urlprops = self.url_handler.stat(url)
             return True
         except UrlOperationsRemoteError as e:
             # leave a trace in the logs
@@ -375,7 +375,7 @@ class UncurlRemote(SpecialRemote):
     def checkpresent(self, key):
         return self._check_retrieve(
             key,
-            self.url_handler.sniff,
+            self.url_handler.stat,
             ('find', 'at'),
         )
 
@@ -514,10 +514,10 @@ class UncurlRemote(SpecialRemote):
             raise RemoteError from e
 
 
-_sniff2checkurl_map = {
+_stat2checkurl_map = {
     'content-length': 'size',
 }
-"""Translate property names returned by AnyUrlOperations.sniff()
+"""Translate property names returned by AnyUrlOperations.stat()
 to those expected from checkurl()"""
 
 
diff --git a/datalad_next/url_operations/__init__.py b/datalad_next/url_operations/__init__.py
index 746671a..f31f6a3 100644
--- a/datalad_next/url_operations/__init__.py
+++ b/datalad_next/url_operations/__init__.py
@@ -45,11 +45,11 @@ class UrlOperations:
             self._cfg = datalad.cfg
         return self._cfg
 
-    def sniff(self,
-              url: str,
-              *,
-              credential: str | None = None,
-              timeout: float | None = None) -> Dict:
+    def stat(self,
+             url: str,
+             *,
+             credential: str | None = None,
+             timeout: float | None = None) -> Dict:
         """Gather information on a URL target, without downloading it
 
         Returns
diff --git a/datalad_next/url_operations/any.py b/datalad_next/url_operations/any.py
index c91d94a..258141d 100644
--- a/datalad_next/url_operations/any.py
+++ b/datalad_next/url_operations/any.py
@@ -164,13 +164,13 @@ class AnyUrlOperations(UrlOperations):
     def is_supported_url(self, url) -> bool:
         return any(r.match(url) for r in self._url_handlers)
 
-    def sniff(self,
-              url: str,
-              *,
-              credential: str | None = None,
-              timeout: float | None = None) -> Dict:
-        """Call `*UrlOperations.sniff()` for the respective URL scheme"""
-        return self._get_handler(url).sniff(
+    def stat(self,
+             url: str,
+             *,
+             credential: str | None = None,
+             timeout: float | None = None) -> Dict:
+        """Call `*UrlOperations.stat()` for the respective URL scheme"""
+        return self._get_handler(url).stat(
             url, credential=credential, timeout=timeout)
 
     def download(self,
diff --git a/datalad_next/url_operations/file.py b/datalad_next/url_operations/file.py
index 71b2676..82bdc09 100644
--- a/datalad_next/url_operations/file.py
+++ b/datalad_next/url_operations/file.py
@@ -39,14 +39,14 @@ class FileUrlOperations(UrlOperations):
         path = request.url2pathname(parsed.path)
         return Path(path)
 
-    def sniff(self,
-              url: str,
-              *,
-              credential: str | None = None,
-              timeout: float | None = None) -> Dict:
+    def stat(self,
+             url: str,
+             *,
+             credential: str | None = None,
+             timeout: float | None = None) -> Dict:
         """Gather information on a URL target, without downloading it
 
-        See :meth:`datalad_next.url_operations.UrlOperations.sniff`
+        See :meth:`datalad_next.url_operations.UrlOperations.stat`
         for parameter documentation and exception behavior.
 
         Raises
@@ -56,11 +56,11 @@ class FileUrlOperations(UrlOperations):
         """
         # filter out internals
         return {
-            k: v for k, v in self._sniff(url, credential).items()
+            k: v for k, v in self._stat(url, credential).items()
             if not k.startswith('_')
         }
 
-    def _sniff(self, url: str, credential: str | None = None) -> Dict:
+    def _stat(self, url: str, credential: str | None = None) -> Dict:
         # turn url into a native path
         from_path = self._file_url_to_path(url)
         # if anything went wrong with the conversion, or we lack
@@ -96,7 +96,7 @@ class FileUrlOperations(UrlOperations):
         """
         dst_fp = None
         try:
-            props = self._sniff(from_url, credential=credential)
+            props = self._stat(from_url, credential=credential)
             from_path = props['_path']
             expected_size = props['content-length']
             dst_fp = sys.stdout.buffer if to_path is None \
@@ -118,7 +118,7 @@ class FileUrlOperations(UrlOperations):
             # would be a local issue, pass-through
             raise
         except UrlOperationsResourceUnknown:
-            # would come from sniff(), pass_through
+            # would come from stat(), pass_through
             raise
         except Exception as e:
             # wrap this into the datalad-standard, but keep the
diff --git a/datalad_next/url_operations/http.py b/datalad_next/url_operations/http.py
index aa0f05f..8700b7b 100644
--- a/datalad_next/url_operations/http.py
+++ b/datalad_next/url_operations/http.py
@@ -47,14 +47,14 @@ class HttpUrlOperations(UrlOperations):
             hdrs.update(headers)
         return hdrs
 
-    def sniff(self,
-              url: str,
-              *,
-              credential: str | None = None,
-              timeout: float | None = None) -> Dict:
+    def stat(self,
+             url: str,
+             *,
+             credential: str | None = None,
+             timeout: float | None = None) -> Dict:
         """Gather information on a URL target, without downloading it
 
-        See :meth:`datalad_next.url_operations.UrlOperations.sniff`
+        See :meth:`datalad_next.url_operations.UrlOperations.stat`
         for parameter documentation and exception behavior.
 
         Raises
@@ -96,7 +96,7 @@ class HttpUrlOperations(UrlOperations):
             }
             props['url'] = r.url
         auth.save_entered_credential(
-            context=f'sniffing {url}'
+            context=f"for accessing {url}"
         )
         if 'content-length' in props:
             # make an effort to return size in bytes as int
diff --git a/datalad_next/url_operations/ssh.py b/datalad_next/url_operations/ssh.py
index f685a81..a79d30a 100644
--- a/datalad_next/url_operations/ssh.py
+++ b/datalad_next/url_operations/ssh.py
@@ -70,18 +70,18 @@ class SshUrlOperations(UrlOperations):
                 "|| exit 244"
     _cat_cmd = "cat '{fpath}'"
 
-    def sniff(self,
-              url: str,
-              *,
-              credential: str | None = None,
-              timeout: float | None = None) -> Dict:
+    def stat(self,
+             url: str,
+             *,
+             credential: str | None = None,
+             timeout: float | None = None) -> Dict:
         """Gather information on a URL target, without downloading it
 
-        See :meth:`datalad_next.url_operations.UrlOperations.sniff`
+        See :meth:`datalad_next.url_operations.UrlOperations.stat`
         for parameter documentation and exception behavior.
         """
         try:
-            props = self._sniff(
+            props = self._stat(
                 url,
                 cmd=SshUrlOperations._stat_cmd,
             )
@@ -94,7 +94,7 @@ class SshUrlOperations(UrlOperations):
 
         return {k: v for k, v in props.items() if not k.startswith('_')}
 
-    def _sniff(self, url: str, cmd: str) -> Dict:
+    def _stat(self, url: str, cmd: str) -> Dict:
         # any stream must start with this magic marker, or we do not
         # recognize what is happening
         # after this marker, the server will send the size of the
@@ -164,7 +164,7 @@ class SshUrlOperations(UrlOperations):
         dst_fp = None
 
         try:
-            props = self._sniff(
+            props = self._stat(
                 from_url,
                 cmd=f'{SshUrlOperations._stat_cmd}; {SshUrlOperations._cat_cmd}',
             )


Test patch:
diff --git a/datalad_next/url_operations/tests/test_any.py b/datalad_next/url_operations/tests/test_any.py
index e7be8bb..71ad2a3 100644
--- a/datalad_next/url_operations/tests/test_any.py
+++ b/datalad_next/url_operations/tests/test_any.py
@@ -32,11 +32,11 @@ def test_any_url_operations(tmp_path):
     ops = AnyUrlOperations()
     # no target file (yet), precise exception
     with pytest.raises(UrlOperationsResourceUnknown):
-        ops.sniff(test_url)
+        ops.stat(test_url)
     # now put something at the target location
     test_path.write_text('surprise!')
     # and now it works
-    props = ops.sniff(test_url)
+    props = ops.stat(test_url)
     # we get the correct file size reported
     assert props['content-length'] == test_path.stat().st_size
 
@@ -53,7 +53,7 @@ def test_any_url_operations(tmp_path):
 
     # try some obscure URL scheme
     with pytest.raises(ValueError):
-        ops.sniff('weird://stuff')
+        ops.stat('weird://stuff')
 
     # and it could have been figured out before
     assert ops.is_supported_url('weird://stuff') == False
diff --git a/datalad_next/url_operations/tests/test_file.py b/datalad_next/url_operations/tests/test_file.py
index 7b03c62..1667f92 100644
--- a/datalad_next/url_operations/tests/test_file.py
+++ b/datalad_next/url_operations/tests/test_file.py
@@ -18,11 +18,11 @@ def test_file_url_download(tmp_path):
     ops = FileUrlOperations()
     # no target file (yet), precise exception
     with pytest.raises(UrlOperationsResourceUnknown):
-        ops.sniff(test_url)
+        ops.stat(test_url)
     # now put something at the target location
     test_path.write_text('surprise!')
     # and now it works
-    props = ops.sniff(test_url)
+    props = ops.stat(test_url)
     # we get the correct file size reported
     assert props['content-length'] == test_path.stat().st_size
 
diff --git a/datalad_next/url_operations/tests/test_http.py b/datalad_next/url_operations/tests/test_http.py
index 25d1208..23e6c3f 100644
--- a/datalad_next/url_operations/tests/test_http.py
+++ b/datalad_next/url_operations/tests/test_http.py
@@ -21,15 +21,15 @@ def test_http_url_operations(tmp_path):
     ops = HttpUrlOperations()
     # authentication after redirect
     target_url = f'{hbsurl}/basic-auth/mike/dummy'
-    props = ops.sniff(f'{hbsurl}/redirect-to?url={target_url}')
+    props = ops.stat(f'{hbsurl}/redirect-to?url={target_url}')
     # we get the resolved URL after redirect back
     assert props['url'] == target_url
     # same again, but credentials are wrong
     target_url = f'{hbsurl}/basic-auth/mike/WRONG'
     with pytest.raises(UrlOperationsRemoteError):
-        ops.sniff(f'{hbsurl}/redirect-to?url={target_url}')
+        ops.stat(f'{hbsurl}/redirect-to?url={target_url}')
     # make sure we get the size info
-    assert ops.sniff(f'{hbsurl}/bytes/63')['content-length'] == 63
+    assert ops.stat(f'{hbsurl}/bytes/63')['content-length'] == 63
 
     # download
     # SFRUUEJJTiBpcyBhd2Vzb21l == 'HTTPBIN is awesome'
@@ -40,6 +40,6 @@ def test_http_url_operations(tmp_path):
 
     # 404s
     with pytest.raises(UrlOperationsResourceUnknown):
-        ops.sniff(f'{hbsurl}/status/404')
+        ops.stat(f'{hbsurl}/status/404')
     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 8931964..c229378 100644
--- a/datalad_next/url_operations/tests/test_ssh.py
+++ b/datalad_next/url_operations/tests/test_ssh.py
@@ -24,14 +24,14 @@ def test_ssh_url_download(tmp_path, monkeypatch):
     ops = SshUrlOperations()
     # no target file (yet), precise exception
     with pytest.raises(UrlOperationsResourceUnknown):
-        ops.sniff(test_url)
+        ops.stat(test_url)
     # this is different for a general connection error
     with pytest.raises(UrlOperationsRemoteError):
-        ops.sniff(f'ssh://localhostnotaround{test_path}')
+        ops.stat(f'ssh://localhostnotaround{test_path}')
     # now put something at the target location
     test_path.write_text('surprise!')
     # and now it works
-    props = ops.sniff(test_url)
+    props = ops.stat(test_url)
     # we get the correct file size reported
     assert props['content-length'] == test_path.stat().st_size
 
@@ -41,7 +41,7 @@ def test_ssh_url_download(tmp_path, monkeypatch):
         m.setattr(SshUrlOperations, '_stat_cmd', 'echo nothing')
         # we get a distinct exception
         with pytest.raises(RuntimeError):
-            ops.sniff(test_url)
+            ops.stat(test_url)
 
     # and download
     download_path = tmp_path / 'download'