Repository: haginara/ssh_config

Issue:
Fix "KeyError: 'No'" for "No" values
It is better to change the line at 
https://github.com/haginara/ssh_config/blob/be893bc61387f75d56467e0cb50cd38aa7478d6f/ssh_config/keywords.py#L19

to the following. Otherwise, "No" values would run into `KeyError: 'No'` errors. 

```python
convert[value.lower()]
```

I suggest the code to look like 

```python
def yes_or_no(value: str) -> bool:
    """Convert 'yes' or 'no' to True or False
    Args:
        value (str): The string containing 'yes' or 'no'
    Returns:
        bool: True if value is 'yes', False if value is 'no'
    """
    if value is None:
        return
    if value.lower() not in ('yes', 'no', 'true', 'false'):
        raise TypeError(f"Yes or No is required: {value}")
    convert = {
        "yes": True,
        "no": False,
        "true": True,
        "false": False,
        True: "yes",
        False: "no",
    }
    return convert[value.lower()]
```

Otherwise, I would run into errors when reading back the updated config file. 

Comments:

Patch:
diff --git a/ssh_config/client.py b/ssh_config/client.py
index 5084e87..0032a17 100644
--- a/ssh_config/client.py
+++ b/ssh_config/client.py
@@ -114,9 +114,10 @@ class Host:
         self.set_name(name)
         self.__attrs = {}
         attrs = {key.upper(): value for key, value in attrs.items()}
-        for attr, attr_type in Keywords:
-            if attrs.get(attr.upper()):
-                self.__attrs[attr] = attr_type(attrs.get(attr.upper()))
+        for keyword in Keywords:
+            if attrs.get(keyword.key.upper()):
+                self.__attrs[keyword.key] = keyword.type_converter(
+                    attrs.get(keyword.key.upper()))
 
     def set_name(self, name):
         """Set Host name
@@ -146,7 +147,7 @@ class Host:
         """Get attributes
         Args:
             exclude (List or None): Attributes to exclude
-            include (List or None): Atrributes to include
+            include (List or None): Attributes to include
         """
         if exclude and include:
             raise Exception("exclude and include cannot be together")
@@ -158,6 +159,14 @@ class Host:
             return {key: self.__attrs[key] for key in self.__attrs if key in include}
         return self.__attrs
 
+    def persist_attributes(self):
+        converted_attributes = {}
+        for keyword in Keywords:
+            if keyword.key in self.attributes():
+                converted_attributes[keyword.key] = keyword.persist_converter(
+                    self.get(keyword.key))
+        return converted_attributes
+
     @property
     def name(self):
         """Return name"""
@@ -329,7 +338,7 @@ class SSHConfig:
 
     def write(self, filename=None):
         """Write the current ssh_config to self.config_path or given filename
-        It chagnes the self.config_path, if the filename is given.
+        It changes the self.config_path, if the filename is given.
         Args:
             filename (str): target filename to be written.
         """
@@ -339,8 +348,8 @@ class SSHConfig:
         with open(self.config_path, "w") as f:
             for host in self.hosts:
                 f.write(f"Host {host.name}\n")
-                for attr in host.attributes():
-                    f.write(f"{' '*4}{attr} {host.get(attr)}\n")
+                for attr, value in host.persist_attributes().items():
+                    f.write(f"{' '*4}{attr} {value}\n")
 
     def asdict(self):
         """Return dict from list of hosts
diff --git a/ssh_config/keywords.py b/ssh_config/keywords.py
index 3d0d07e..6a2414f 100644
--- a/ssh_config/keywords.py
+++ b/ssh_config/keywords.py
@@ -1,4 +1,3 @@
-
 def yes_or_no(value: str) -> bool:
     """Convert 'yes' or 'no' to True or False
     Args:
@@ -12,111 +11,129 @@ def yes_or_no(value: str) -> bool:
         raise TypeError("Yes or No is required")
     convert = {
         "yes": True,
-        "no": False,
-        True: "yes",
-        False: "no",
+        "no": False
     }
-    return convert[value]
+    return convert[value.lower()]
+
+
+def yes_or_no_str(value: bool) -> str:
+    """Convert True or False to 'yes' or 'no'
+    Args:
+        value (bool): True/False
+    Returns:
+        str: 'yes' if value is True, 'no' if value is False/None
+    """
+    if value is None:
+        return "no"
+    return "yes" if value else "no"
+
+
+class Keyword:
+    def __init__(self, key: str, type_converter: type,
+                 persist_converter: type = None) -> None:
+        self.key = key
+        self.type_converter = type_converter
+        self.persist_converter = persist_converter if persist_converter else type_converter
 
 
 Keywords = [
-    ("HostName", str),
-    ("User", str),
-    ("Port", int),
-    ("IdentityFile", str),
-    ("AddressFamily", str),  # any, inet, inet6
-    ("BatchMode", str),
-    ("BindAddress", str),
-    ("ChallengeResponseAuthentication", str),  # yes, no
-    ("CheckHostIP", str),  # yes, no
-    ("Cipher", str),
-    ("Ciphers", str),
-    ("ClearAllForwardings", str),  # yes, no
-    ("Compression", str),  # yes, no
-    ("CompressionLevel", int),  # 1 to 9
-    ("ConnectionAttempts", int),  # default: 1
-    ("ConnectTimeout", int),
-    ("ControlMaster", str),  # yes, no
-    ("ControlPath", str),
-    ("DynamicForward", str),  # [bind_address:]port, [bind_adderss/]port
-    ("EnableSSHKeysign", str),  # yes, no
-    ("EscapeChar", str),  # default: '~'
-    ("ExitOnForwardFailure", str),  # yes, no
-    ("ForwardAgent", str),  # yes, no
-    ("ForwardX11", str),  # yes, no
-    ("ForwardX11Trusted", str),  # yes, no
-    ("GatewayPorts", str),  # yes, no
-    ("GlobalKnownHostsFile", str),  # yes, no
-    ("GSSAPIAuthentication", str),  # yes, no
-    ("LocalCommand", str),
-    ("LocalForward", str),
-    ("LogLevel", str),
-    ("ProxyCommand", str),
-    ("ProxyJump", str),
-    ("Match", str),
-    ("AddKeysToAgent", str),
-    ("BindInterface", str),
-    ("CanonicalizeHostname", str),  # yes, no
-    ("CanonicalizeMaxDots", int),
-    ("CanonicalDomains", str),
-    ("CanonicalizePermittedCNAMEs", str),
-    ("CanonicalizeFallbackLocal", str),
-    ("IdentityAgent", str),
-    ("PreferredAuthentications", str),
-    ("ServerAliveInterval", int),
-    ("ServerAliveCountMax", int),
-    ("UsePrivilegedPort", str),  # yes, no
-    ("TCPKeepAlive", str),  # yes, no
-    ("Include", str),
-    ("IPQoS", str),
-    ("GlobalKnownHostsFile", str),
-    ("UserKnownHostsFile", str),
-    ("GSSAPIDelegateCredentials", str),
-    ("PKCS11Provider", str),
-    ("XAuthLocation", str),
-    ("PasswordAuthentication", yes_or_no),  # default: yes
-    ("KbdInteractiveAuthentication", str),
-    ("KbdInteractiveDevices", str),
-    ("PubkeyAuthentication", str),
-    ("HostbasedAuthentication", str),
-    ("IdentitiesOnly", yes_or_no),  # default: no
-    ("CertificateFile", str),
-    ("HostKeyAlias", str),
-    ("MACs", str),
-    ("RemoteForward", str),
-    ("PermitRemoteOpen", str),
-    ("StrictHostKeyChecking", yes_or_no),
-    ("NumberOfPasswordPrompts", str),
-    ("SyslogFacility", str),
-    ("LogVerbose", str),
-    ("HostKeyAlgorithms", str),
-    ("CASignatureAlgorithms", str),
-    ("VerifyHostKeyDNS", str),
-    ("NoHostAuthenticationForLocalhost", str),
-    ("RekeyLimit", str),
-    ("SendEnv", str),
-    ("SetEnv", str),
-    ("ControlPersist", str),
-    ("HashKnownHosts", str),
-    ("Tunnel", str),
-    ("TunnelDevice", str),
-    ("PermitLocalCommand", str),
-    ("RemoteCommand", str),
-    ("VisualHostKey", str),
-    ("KexAlgorithms", str),
-    ("RequestTTY", str),
-    ("SessionType", str),
-    ("StdinNull", str),
-    ("ForkAfterAuthentication", str),
-    ("ProxyUseFdpass", str),
-    ("StreamLocalBindMask", str),
-    ("StreamLocalBindUnlink", str),
-    ("RevokedHostKeys", str),
-    ("FingerprintHash", str),  # md5 or sha256
-    ("UpdateHostKeys", str),
-    ("HostbasedAcceptedAlgorithms", str),
-    ("PubkeyAcceptedAlgorithms", str),
-    ("IgnoreUnknown", str),
-    ("SecurityKeyProvider", str),
-    ("KnownHostsCommand", str),
+    Keyword("HostName", str),
+    Keyword("User", str),
+    Keyword("Port", int),
+    Keyword("IdentityFile", str),
+    Keyword("AddressFamily", str),  # any, inet, inet6
+    Keyword("BatchMode", str),
+    Keyword("BindAddress", str),
+    Keyword("ChallengeResponseAuthentication", str),  # yes, no
+    Keyword("CheckHostIP", str),  # yes, no
+    Keyword("Cipher", str),
+    Keyword("Ciphers", str),
+    Keyword("ClearAllForwardings", str),  # yes, no
+    Keyword("Compression", str),  # yes, no
+    Keyword("CompressionLevel", int),  # 1 to 9
+    Keyword("ConnectionAttempts", int),  # default: 1
+    Keyword("ConnectTimeout", int),
+    Keyword("ControlMaster", str),  # yes, no
+    Keyword("ControlPath", str),
+    Keyword("DynamicForward", str),  # [bind_address:]port, [bind_adderss/]port
+    Keyword("EnableSSHKeysign", str),  # yes, no
+    Keyword("EscapeChar", str),  # default: '~'
+    Keyword("ExitOnForwardFailure", str),  # yes, no
+    Keyword("ForwardAgent", str),  # yes, no
+    Keyword("ForwardX11", str),  # yes, no
+    Keyword("ForwardX11Trusted", str),  # yes, no
+    Keyword("GatewayPorts", str),  # yes, no
+    Keyword("GlobalKnownHostsFile", str),  # yes, no
+    Keyword("GSSAPIAuthentication", str),  # yes, no
+    Keyword("LocalCommand", str),
+    Keyword("LocalForward", str),
+    Keyword("LogLevel", str),
+    Keyword("ProxyCommand", str),
+    Keyword("ProxyJump", str),
+    Keyword("Match", str),
+    Keyword("AddKeysToAgent", str),
+    Keyword("BindInterface", str),
+    Keyword("CanonicalizeHostname", str),  # yes, no
+    Keyword("CanonicalizeMaxDots", int),
+    Keyword("CanonicalDomains", str),
+    Keyword("CanonicalizePermittedCNAMEs", str),
+    Keyword("CanonicalizeFallbackLocal", str),
+    Keyword("IdentityAgent", str),
+    Keyword("PreferredAuthentications", str),
+    Keyword("ServerAliveInterval", int),
+    Keyword("ServerAliveCountMax", int),
+    Keyword("UsePrivilegedPort", str),  # yes, no
+    Keyword("TCPKeepAlive", str),  # yes, no
+    Keyword("Include", str),
+    Keyword("IPQoS", str),
+    Keyword("GlobalKnownHostsFile", str),
+    Keyword("UserKnownHostsFile", str),
+    Keyword("GSSAPIDelegateCredentials", str),
+    Keyword("PKCS11Provider", str),
+    Keyword("XAuthLocation", str),
+    Keyword("PasswordAuthentication", yes_or_no, yes_or_no_str),  # default: yes
+    Keyword("KbdInteractiveAuthentication", str),
+    Keyword("KbdInteractiveDevices", str),
+    Keyword("PubkeyAuthentication", str),
+    Keyword("HostbasedAuthentication", str),
+    Keyword("IdentitiesOnly", yes_or_no, yes_or_no_str),  # default: no
+    Keyword("CertificateFile", str),
+    Keyword("HostKeyAlias", str),
+    Keyword("MACs", str),
+    Keyword("RemoteForward", str),
+    Keyword("PermitRemoteOpen", str),
+    Keyword("StrictHostKeyChecking", yes_or_no, yes_or_no_str),
+    Keyword("NumberOfPasswordPrompts", str),
+    Keyword("SyslogFacility", str),
+    Keyword("LogVerbose", str),
+    Keyword("HostKeyAlgorithms", str),
+    Keyword("CASignatureAlgorithms", str),
+    Keyword("VerifyHostKeyDNS", str),
+    Keyword("NoHostAuthenticationForLocalhost", str),
+    Keyword("RekeyLimit", str),
+    Keyword("SendEnv", str),
+    Keyword("SetEnv", str),
+    Keyword("ControlPersist", str),
+    Keyword("HashKnownHosts", str),
+    Keyword("Tunnel", str),
+    Keyword("TunnelDevice", str),
+    Keyword("PermitLocalCommand", str),
+    Keyword("RemoteCommand", str),
+    Keyword("VisualHostKey", str),
+    Keyword("KexAlgorithms", str),
+    Keyword("RequestTTY", str),
+    Keyword("SessionType", str),
+    Keyword("StdinNull", str),
+    Keyword("ForkAfterAuthentication", str),
+    Keyword("ProxyUseFdpass", str),
+    Keyword("StreamLocalBindMask", str),
+    Keyword("StreamLocalBindUnlink", str),
+    Keyword("RevokedHostKeys", str),
+    Keyword("FingerprintHash", str),  # md5 or sha256
+    Keyword("UpdateHostKeys", str),
+    Keyword("HostbasedAcceptedAlgorithms", str),
+    Keyword("PubkeyAcceptedAlgorithms", str),
+    Keyword("IgnoreUnknown", str),
+    Keyword("SecurityKeyProvider", str),
+    Keyword("KnownHostsCommand", str),
 ]


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index ba579d1..aa0e09f 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -16,11 +16,12 @@ from ssh_config.errors import EmptySSHConfig, WrongSSHConfig, HostExistsError
 logging.basicConfig(level=logging.INFO)
 sample = os.path.join(os.path.dirname(__file__), "sample")
 
-new_host = Host("server2", {"ServerAliveInterval": 200, "HostName": "203.0.113.77"})
+new_host = Host("server2", {"ServerAliveInterval": 200, "HostName": "203.0.113.77", "StrictHostKeyChecking": "no"})
 
 new_data = """Host server2
     HostName 203.0.113.77
     ServerAliveInterval 200
+    StrictHostKeyChecking no
 """