Create the configuration directory upon saving, if needed
Marco Ricci

Marco Ricci commited on 2024-07-28 15:02:34
Zeige 4 geänderte Dateien mit 102 Einfügungen und 1 Löschungen.


Do not error out during the first attempt at saving the configuration,
where the configuration directory probably does not yet exist.

On an unrelated note, fix one typo in `derivepassphrase.cli` and
a missing dependency in the hatch environment for type checking.
... ...
@@ -89,6 +89,7 @@ detached = false
89 89
 [tool.hatch.envs.types]
90 90
 extra-dependencies = [
91 91
   "mypy>=1.0.0",
92
+  "pytest~=8.1",
92 93
 ]
93 94
 [tool.hatch.envs.types.scripts]
94 95
 check = "mypy --install-types --non-interactive {args:src/derivepassphrase tests}"
... ...
@@ -97,7 +97,7 @@ def _load_config() -> dpp_types.VaultConfig:
97 97
 
98 98
 
99 99
 def _save_config(config: dpp_types.VaultConfig, /) -> None:
100
-    """Save a vault(1)-compatbile config to the application directory.
100
+    """Save a vault(1)-compatible config to the application directory.
101 101
 
102 102
     The filename is obtained via
103 103
     [`derivepassphrase.cli._config_filename`][].  The config will be
... ...
@@ -117,6 +117,12 @@ def _save_config(config: dpp_types.VaultConfig, /) -> None:
117 117
     if not dpp_types.is_vault_config(config):
118 118
         raise ValueError(_INVALID_VAULT_CONFIG)
119 119
     filename = _config_filename()
120
+    filedir = os.path.dirname(os.path.abspath(filename))
121
+    try:
122
+        os.makedirs(filedir, exist_ok=False)
123
+    except FileExistsError:
124
+        if not os.path.isdir(filedir):
125
+            raise
120 126
     with open(filename, 'w', encoding='UTF-8') as fileobj:
121 127
         json.dump(config, fileobj)
122 128
 
... ...
@@ -7,6 +7,7 @@ from __future__ import annotations
7 7
 import contextlib
8 8
 import json
9 9
 import os
10
+import shutil
10 11
 import socket
11 12
 from typing import TYPE_CHECKING
12 13
 
... ...
@@ -686,6 +687,32 @@ class TestCLI:
686 687
                 b'cannot write config' in result.stderr_bytes
687 688
             ), 'program did not print the expected error message'
688 689
 
690
+    def test_214d_export_settings_settings_directory_not_a_directory(
691
+        self,
692
+        monkeypatch: Any,
693
+    ) -> None:
694
+        runner = click.testing.CliRunner(mix_stderr=False)
695
+        with tests.isolated_config(
696
+            monkeypatch=monkeypatch, runner=runner, config={'services': {}}
697
+        ):
698
+            with contextlib.suppress(FileNotFoundError):
699
+                shutil.rmtree('.derivepassphrase')
700
+            with open('.derivepassphrase', 'w', encoding='UTF-8') as outfile:
701
+                print('Obstruction!!', file=outfile)
702
+            result = runner.invoke(
703
+                cli.derivepassphrase,
704
+                ['--export', '-'],
705
+                input=b'null',
706
+                catch_exceptions=False,
707
+            )
708
+            assert result.exit_code > 0, 'program unexpectedly succeeded'
709
+            assert (
710
+                result.stderr_bytes
711
+            ), 'program did not print any error message'
712
+            assert (
713
+                b'cannot load config' in result.stderr_bytes
714
+            ), 'program did not print the expected error message'
715
+
689 716
     def test_220_edit_notes_successfully(self, monkeypatch: Any) -> None:
690 717
         edit_result = """
691 718
 
... ...
@@ -946,6 +973,72 @@ contents go here
946 973
             b'no passphrase or key given' in result.stderr_bytes
947 974
         ), 'expected error message missing'
948 975
 
976
+    def test_230_config_directory_nonexistant(self, monkeypatch: Any) -> None:
977
+        """the-13th-letter/derivepassphrase#6"""
978
+        runner = click.testing.CliRunner(mix_stderr=False)
979
+        with tests.isolated_config(
980
+            monkeypatch=monkeypatch,
981
+            runner=runner,
982
+            config={'services': {}},
983
+        ):
984
+            os.remove('.derivepassphrase/settings.json')
985
+            os.rmdir('.derivepassphrase')
986
+            os_makedirs_called = False
987
+            real_os_makedirs = os.makedirs
988
+
989
+            def makedirs(*args: Any, **kwargs: Any) -> Any:
990
+                nonlocal os_makedirs_called
991
+                os_makedirs_called = True
992
+                return real_os_makedirs(*args, **kwargs)
993
+
994
+            monkeypatch.setattr(os, 'makedirs', makedirs)
995
+            result = runner.invoke(
996
+                cli.derivepassphrase,
997
+                ['--config', '-p'],
998
+                catch_exceptions=False,
999
+                input='abc\n',
1000
+            )
1001
+            assert (
1002
+                result.stderr_bytes == b'Passphrase:'
1003
+            ), 'program unexpectedly failed?!'
1004
+            assert result.exit_code == 0, 'program unexpectedly failed?!'
1005
+            assert os_makedirs_called, 'os.makedirs has not been called?!'
1006
+            with open(cli._config_filename(), encoding='UTF-8') as infile:
1007
+                config_readback = json.load(infile)
1008
+            assert config_readback == {
1009
+                'global': {'phrase': 'abc'},
1010
+                'services': {},
1011
+            }, 'config mismatch'
1012
+
1013
+    def test_230a_config_directory_not_a_file(self, monkeypatch: Any) -> None:
1014
+        """the-13th-letter/derivepassphrase#6"""
1015
+        runner = click.testing.CliRunner(mix_stderr=False)
1016
+        with tests.isolated_config(
1017
+            monkeypatch=monkeypatch,
1018
+            runner=runner,
1019
+            config={'services': {}},
1020
+        ):
1021
+            _save_config = cli._save_config
1022
+
1023
+            def obstruct_config_saving(*args: Any, **kwargs: Any) -> Any:
1024
+                with contextlib.suppress(FileNotFoundError):
1025
+                    shutil.rmtree('.derivepassphrase')
1026
+                with open(
1027
+                    '.derivepassphrase', 'w', encoding='UTF-8'
1028
+                ) as outfile:
1029
+                    print('Obstruction!!', file=outfile)
1030
+                monkeypatch.setattr(cli, '_save_config', _save_config)
1031
+                return _save_config(*args, **kwargs)
1032
+
1033
+            monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1034
+            with pytest.raises(FileExistsError):
1035
+                runner.invoke(
1036
+                    cli.derivepassphrase,
1037
+                    ['--config', '-p'],
1038
+                    catch_exceptions=False,
1039
+                    input='abc\n',
1040
+                )
1041
+
949 1042
 
950 1043
 class TestCLIUtils:
951 1044
     def test_100_save_bad_config(self, monkeypatch: Any) -> None:
... ...
@@ -0,0 +1 @@
1
+Create the configuration directory upon saving, if needed.
0 2