Repository: piskvorky/smart_open

Issue:
(SFTP) KeyError on cfg["port"] when port not explicitly defined for host in ~/.ssh/config
I have a large number of hosts defined in my `~/.ssh/config` file, but generally I don't define `Port 22` because that is the standard default. However, when I try to `open()` a `sftp://` URL with no explicit port, I get a `KeyError` because, although that host's configuration is defined (meaning `cfg` is populated to connect to this host), the `Port` for it is not. I suggest that `cfg["port"]` be changed to `cfg.get("port", 22)` or use `cfg.setdefault("port", 22)` when not defined for that host.

(Referring to line 182 of `smart_open/ssh.py`)

I originally tried to override this using `transport_params={"port": 22}` but that does not seem to override the config either.


Discussion:
PR is welcome! ssh.py already uses `setdefault` so I'd suggest to go with that. adding a test that demonstrates this bug would be great
ah, scratch that. I just looked at the code. PR incoming
thanks for reporting! https://github.com/piskvorky/smart_open/pull/863



Patch:
diff --git a/smart_open/ssh.py b/smart_open/ssh.py
index 5a584546..47848bb4 100644
--- a/smart_open/ssh.py
+++ b/smart_open/ssh.py
@@ -115,7 +115,8 @@ def open_uri(uri, mode, transport_params):
     parsed_uri = parse_uri(uri)
     uri_path = parsed_uri.pop('uri_path')
     parsed_uri.pop('scheme')
-    return open(uri_path, mode, **parsed_uri, **kwargs)
+    final_params = {**parsed_uri, **kwargs}  # transport_params takes precedence over uri
+    return open(uri_path, mode, **final_params)
 
 
 def _connect_ssh(hostname, username, port, password, connect_kwargs):
@@ -180,7 +181,7 @@ def _maybe_fetch_config(host, username=None, password=None, port=None, connect_k
         if port is None:
             try:
                 port = int(cfg["port"])
-            except (IndexError, ValueError):
+            except (KeyError, ValueError):
                 #
                 # Nb. ignore missing/invalid port numbers
                 #


Test patch:
diff --git a/smart_open/tests/test_data/ssh.cfg b/smart_open/tests/test_data/ssh.cfg
index c03c4e7a..50a7147f 100644
--- a/smart_open/tests/test_data/ssh.cfg
+++ b/smart_open/tests/test_data/ssh.cfg
@@ -9,3 +9,14 @@ Host another-host
   GSSAPIKeyExchange no
   GSSAPIDelegateCredentials no
   GSSAPITrustDns no
+
+Host another-host-missing-port
+  HostName another-host-domain.com
+  User another-user
+  IdentityFile /path/to/key/file
+  ConnectTimeout 20
+  Compression yes
+  GSSAPIAuthentication no
+  GSSAPIKeyExchange no
+  GSSAPIDelegateCredentials no
+  GSSAPITrustDns no
diff --git a/smart_open/tests/test_ssh.py b/smart_open/tests/test_ssh.py
index 7b99d98f..85b06efa 100644
--- a/smart_open/tests/test_ssh.py
+++ b/smart_open/tests/test_ssh.py
@@ -111,6 +111,38 @@ def test_open_with_openssh_config_override_port(self, mock_connect, get_transp_m
             gss_trust_dns=False,
         )
 
+    @mock_ssh
+    def test_open_with_openssh_config_override_port2(self, mock_connect, get_transp_mock):
+        smart_open.open("ssh://another-host/", transport_params={"port": 22})
+        mock_connect.assert_called_with(
+            "another-host-domain.com",
+            22,
+            username="another-user",
+            key_filename=["/path/to/key/file"],
+            timeout=20.,
+            compress=True,
+            gss_auth=False,
+            gss_kex=False,
+            gss_deleg_creds=False,
+            gss_trust_dns=False,
+        )
+
+    @mock_ssh
+    def test_open_with_openssh_config_missing_port(self, mock_connect, get_transp_mock):
+        smart_open.open("ssh://another-host-missing-port/")
+        mock_connect.assert_called_with(
+            "another-host-domain.com",
+            22,
+            username="another-user",
+            key_filename=["/path/to/key/file"],
+            timeout=20.,
+            compress=True,
+            gss_auth=False,
+            gss_kex=False,
+            gss_deleg_creds=False,
+            gss_trust_dns=False,
+        )
+
     @mock_ssh
     def test_open_with_openssh_config_override_user(self, mock_connect, get_transp_mock):
         smart_open.open("ssh://new-user@another-host/")