Introduce a central user configuration file (and division of responsibility)
Marco Ricci

Marco Ricci commited on 2024-12-19 15:04:11
Zeige 4 geänderte Dateien mit 92 Einfügungen und 31 Löschungen.


Introduce a new file for centralized user configuration of
`derivepassphrase`, which we expect the user to write and maintain, and
which we will never attempt to write to.  Treat the existing "vault"
configuration file as a "data" file, which is `derivepassphrase`'s
responsibility to write and to maintain, and which the user shouldn't
edit directly.

In general, we now divide the configuration file set semantically into
"user" files and "data" files with the aforementioned responsibilities:
"user" files are managed by the user, "data" files are managed by the
application.  (This mirrors the distinction in the XDG Base Directory
Specification, although we do not use their paths or their environment
variables.)  We choose TOML as the file format for "user" files, because
it is the simplest configuration file format with both a robust parser
in the Python standard library and support for comments.  The "data"
files currently use JSON, but may use other serialization formats in the
future as well.

We envision some use cases for the user configuration file such as
disabling certain expected warnings, or declaring the desired Unicode
normalization form of a text string, but as of now, no such
configuration is defined or supported.
... ...
@@ -33,6 +33,9 @@ dependencies = [
33 33
   # available in older Pythons (such as typing.Self).  These are loaded from
34 34
   # typing_extensions, instead of using explicit version guards.
35 35
   "typing_extensions",
36
+  # We read configuration files in JSON and TOML format.  The latter is
37
+  # unavailable in the Python standard library until Python 3.11.
38
+  'tomli; python_version < "3.11"'
36 39
 ]
37 40
 dynamic = ['version']
38 41
 
... ...
@@ -17,6 +17,7 @@ import inspect
17 17
 import json
18 18
 import logging
19 19
 import os
20
+import sys
20 21
 import unicodedata
21 22
 import warnings
22 23
 from typing import (
... ...
@@ -40,6 +41,11 @@ from typing_extensions import (
40 41
 import derivepassphrase as dpp
41 42
 from derivepassphrase import _types, exporter, ssh_agent, vault
42 43
 
44
+if sys.version_info >= (3, 11):
45
+    import tomllib
46
+else:
47
+    import tomli as tomllib
48
+
43 49
 if TYPE_CHECKING:
44 50
     import pathlib
45 51
     import socket
... ...
@@ -914,6 +920,8 @@ def _config_filename(
914 920
         return path
915 921
     elif subsystem == 'vault':  # noqa: RET505
916 922
         filename = f'{subsystem}.json'
923
+    elif subsystem == 'user configuration':
924
+        filename = 'config.toml'
917 925
     elif subsystem == 'old settings.json':
918 926
         filename = 'settings.json'
919 927
     else:  # pragma: no cover
... ...
@@ -1013,6 +1021,27 @@ def _save_config(config: _types.VaultConfig, /) -> None:
1013 1021
         json.dump(config, fileobj)
1014 1022
 
1015 1023
 
1024
+def _load_user_config() -> dict[str, Any]:
1025
+    """Load the user config from the application directory.
1026
+
1027
+    The filename is obtained via [`_config_filename`][].
1028
+
1029
+    Returns:
1030
+        The user configuration, as a nested `dict`.
1031
+
1032
+    Raises:
1033
+        OSError:
1034
+            There was an OS error accessing the file.
1035
+        ValueError:
1036
+            The data loaded from the file is not a valid configuration
1037
+            file.
1038
+
1039
+    """
1040
+    filename = _config_filename(subsystem='user configuration')
1041
+    with open(filename, 'rb') as fileobj:
1042
+        return tomllib.load(fileobj)
1043
+
1044
+
1016 1045
 def _get_suitable_ssh_keys(
1017 1046
     conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /
1018 1047
 ) -> Iterator[_types.KeyCommentPair]:
... ...
@@ -1780,6 +1809,16 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1780 1809
         except Exception as exc:  # noqa: BLE001
1781 1810
             err('Cannot store config: %s', str(exc), exc_info=exc)
1782 1811
 
1812
+    def get_user_config() -> dict[str, Any]:
1813
+        try:
1814
+            return _load_user_config()
1815
+        except FileNotFoundError:
1816
+            return {}
1817
+        except OSError as e:
1818
+            err('Cannot load user config: %s: %r', e.strerror, e.filename)
1819
+        except Exception as e:  # noqa: BLE001
1820
+            err('Cannot load user config: %s', str(e), exc_info=e)
1821
+
1783 1822
     configuration: _types.VaultConfig
1784 1823
 
1785 1824
     check_incompatible_options('--phrase', '--key')
... ...
@@ -1822,6 +1861,8 @@ def derivepassphrase_vault(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
1822 1861
             msg = f'{opt_str} does not take a SERVICE argument'
1823 1862
             raise click.UsageError(msg)
1824 1863
 
1864
+    user_config = get_user_config()
1865
+
1825 1866
     if service == '':  # noqa: PLC1901
1826 1867
         logger.warning(
1827 1868
             'An empty SERVICE is not supported by vault(1).  '
... ...
@@ -1429,6 +1429,7 @@ def phrase_from_key(
1429 1429
 def isolated_config(
1430 1430
     monkeypatch: pytest.MonkeyPatch,
1431 1431
     runner: click.testing.CliRunner,
1432
+    main_config_str: str | None = None,
1432 1433
 ) -> Iterator[None]:
1433 1434
     prog_name = cli.PROG_NAME
1434 1435
     env_name = prog_name.replace(' ', '_').upper() + '_PATH'
... ...
@@ -1445,6 +1446,13 @@ def isolated_config(
1445 1446
         monkeypatch.delenv(env_name, raising=False)
1446 1447
         config_dir = cli._config_filename(subsystem=None)
1447 1448
         os.makedirs(config_dir, exist_ok=True)
1449
+        if isinstance(main_config_str, str):
1450
+            with open(
1451
+                cli._config_filename('user configuration'),
1452
+                'w',
1453
+                encoding='UTF-8',
1454
+            ) as outfile:
1455
+                outfile.write(main_config_str)
1448 1456
         yield
1449 1457
 
1450 1458
 
... ...
@@ -1452,12 +1460,15 @@ def isolated_config(
1452 1460
 def isolated_vault_config(
1453 1461
     monkeypatch: pytest.MonkeyPatch,
1454 1462
     runner: click.testing.CliRunner,
1455
-    config: Any,
1463
+    vault_config: Any,
1464
+    main_config_str: str | None = None,
1456 1465
 ) -> Iterator[None]:
1457
-    with isolated_config(monkeypatch=monkeypatch, runner=runner):
1466
+    with isolated_config(
1467
+        monkeypatch=monkeypatch, runner=runner, main_config_str=main_config_str
1468
+    ):
1458 1469
         config_filename = cli._config_filename(subsystem='vault')
1459 1470
         with open(config_filename, 'w', encoding='UTF-8') as outfile:
1460
-            json.dump(config, outfile)
1471
+            json.dump(vault_config, outfile)
1461 1472
         yield
1462 1473
 
1463 1474
 
... ...
@@ -327,7 +327,7 @@ class TestCLI:
327 327
     ) -> None:
328 328
         runner = click.testing.CliRunner(mix_stderr=False)
329 329
         with tests.isolated_vault_config(
330
-            monkeypatch=monkeypatch, runner=runner, config=config
330
+            monkeypatch=monkeypatch, runner=runner, vault_config=config
331 331
         ):
332 332
             monkeypatch.setattr(
333 333
                 vault.Vault, 'phrase_from_key', tests.phrase_from_key
... ...
@@ -356,7 +356,7 @@ class TestCLI:
356 356
         with tests.isolated_vault_config(
357 357
             monkeypatch=monkeypatch,
358 358
             runner=runner,
359
-            config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
359
+            vault_config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
360 360
         ):
361 361
             monkeypatch.setattr(
362 362
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
... ...
@@ -420,7 +420,7 @@ class TestCLI:
420 420
             monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', tests.sign)
421 421
             runner = click.testing.CliRunner(mix_stderr=False)
422 422
             with tests.isolated_vault_config(
423
-                monkeypatch=monkeypatch, runner=runner, config=config
423
+                monkeypatch=monkeypatch, runner=runner, vault_config=config
424 424
             ):
425 425
                 _result = runner.invoke(
426 426
                     cli.derivepassphrase_vault,
... ...
@@ -450,7 +450,7 @@ class TestCLI:
450 450
             with tests.isolated_vault_config(
451 451
                 monkeypatch=monkeypatch,
452 452
                 runner=runner,
453
-                config={
453
+                vault_config={
454 454
                     'global': {'key': DUMMY_KEY1_B64},
455 455
                     'services': {
456 456
                         DUMMY_SERVICE: {
... ...
@@ -510,7 +510,7 @@ class TestCLI:
510 510
             with tests.isolated_vault_config(
511 511
                 monkeypatch=monkeypatch,
512 512
                 runner=runner,
513
-                config=config,
513
+                vault_config=config,
514 514
             ):
515 515
                 _result = runner.invoke(
516 516
                     cli.derivepassphrase_vault,
... ...
@@ -587,7 +587,7 @@ class TestCLI:
587 587
         with tests.isolated_vault_config(
588 588
             monkeypatch=monkeypatch,
589 589
             runner=runner,
590
-            config={'global': {'phrase': 'abc'}, 'services': {}},
590
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
591 591
         ):
592 592
             _result = runner.invoke(
593 593
                 cli.derivepassphrase_vault,
... ...
@@ -613,7 +613,7 @@ class TestCLI:
613 613
             with tests.isolated_vault_config(
614 614
                 monkeypatch=monkeypatch,
615 615
                 runner=runner,
616
-                config={'global': {'phrase': 'abc'}, 'services': {}},
616
+                vault_config={'global': {'phrase': 'abc'}, 'services': {}},
617 617
             ):
618 618
                 monkeypatch.setattr(
619 619
                     cli, '_prompt_for_passphrase', tests.auto_prompt
... ...
@@ -644,7 +644,7 @@ class TestCLI:
644 644
         with tests.isolated_vault_config(
645 645
             monkeypatch=monkeypatch,
646 646
             runner=runner,
647
-            config={'services': {}},
647
+            vault_config={'services': {}},
648 648
         ):
649 649
             _result = runner.invoke(
650 650
                 cli.derivepassphrase_vault,
... ...
@@ -727,7 +727,7 @@ class TestCLI:
727 727
         with tests.isolated_vault_config(
728 728
             monkeypatch=monkeypatch,
729 729
             runner=runner,
730
-            config={'services': {}},
730
+            vault_config={'services': {}},
731 731
         ):
732 732
             _result = runner.invoke(
733 733
                 cli.derivepassphrase_vault,
... ...
@@ -766,7 +766,7 @@ class TestCLI:
766 766
         with tests.isolated_vault_config(
767 767
             monkeypatch=pytest.MonkeyPatch(),
768 768
             runner=runner,
769
-            config={'services': {}},
769
+            vault_config={'services': {}},
770 770
         ):
771 771
             _result = runner.invoke(
772 772
                 cli.derivepassphrase_vault,
... ...
@@ -866,7 +866,7 @@ class TestCLI:
866 866
     ) -> None:
867 867
         runner = click.testing.CliRunner(mix_stderr=False)
868 868
         with tests.isolated_vault_config(
869
-            monkeypatch=monkeypatch, runner=runner, config={}
869
+            monkeypatch=monkeypatch, runner=runner, vault_config={}
870 870
         ):
871 871
             _result = runner.invoke(
872 872
                 cli.derivepassphrase_vault,
... ...
@@ -936,6 +936,8 @@ class TestCLI:
936 936
         result = tests.ReadableResult.parse(_result)
937 937
         assert result.error_exit(
938 938
             error='Cannot load config'
939
+        ) or result.error_exit(
940
+            error='Cannot load user config'
939 941
         ), 'expected error exit and known error message'
940 942
 
941 943
     def test_220_edit_notes_successfully(
... ...
@@ -950,7 +952,7 @@ contents go here
950 952
         with tests.isolated_vault_config(
951 953
             monkeypatch=monkeypatch,
952 954
             runner=runner,
953
-            config={'global': {'phrase': 'abc'}, 'services': {}},
955
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
954 956
         ):
955 957
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: edit_result)  # noqa: ARG005
956 958
             _result = runner.invoke(
... ...
@@ -976,7 +978,7 @@ contents go here
976 978
         with tests.isolated_vault_config(
977 979
             monkeypatch=monkeypatch,
978 980
             runner=runner,
979
-            config={'global': {'phrase': 'abc'}, 'services': {}},
981
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
980 982
         ):
981 983
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)  # noqa: ARG005
982 984
             _result = runner.invoke(
... ...
@@ -999,7 +1001,7 @@ contents go here
999 1001
         with tests.isolated_vault_config(
1000 1002
             monkeypatch=monkeypatch,
1001 1003
             runner=runner,
1002
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1004
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1003 1005
         ):
1004 1006
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')  # noqa: ARG005
1005 1007
             _result = runner.invoke(
... ...
@@ -1025,7 +1027,7 @@ contents go here
1025 1027
         with tests.isolated_vault_config(
1026 1028
             monkeypatch=monkeypatch,
1027 1029
             runner=runner,
1028
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1030
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1029 1031
         ):
1030 1032
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')  # noqa: ARG005
1031 1033
             _result = runner.invoke(
... ...
@@ -1096,7 +1098,7 @@ contents go here
1096 1098
         with tests.isolated_vault_config(
1097 1099
             monkeypatch=monkeypatch,
1098 1100
             runner=runner,
1099
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1101
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1100 1102
         ):
1101 1103
             monkeypatch.setattr(
1102 1104
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
... ...
@@ -1141,7 +1143,7 @@ contents go here
1141 1143
         with tests.isolated_vault_config(
1142 1144
             monkeypatch=monkeypatch,
1143 1145
             runner=runner,
1144
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1146
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1145 1147
         ):
1146 1148
             monkeypatch.setattr(
1147 1149
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
... ...
@@ -1165,7 +1167,7 @@ contents go here
1165 1167
         with tests.isolated_vault_config(
1166 1168
             monkeypatch=monkeypatch,
1167 1169
             runner=runner,
1168
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1170
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1169 1171
         ):
1170 1172
             custom_error = 'custom error message'
1171 1173
 
... ...
@@ -1193,7 +1195,7 @@ contents go here
1193 1195
         with tests.isolated_vault_config(
1194 1196
             monkeypatch=monkeypatch,
1195 1197
             runner=runner,
1196
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1198
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1197 1199
         ):
1198 1200
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
1199 1201
             _result = runner.invoke(
... ...
@@ -1214,7 +1216,7 @@ contents go here
1214 1216
         with tests.isolated_vault_config(
1215 1217
             monkeypatch=monkeypatch,
1216 1218
             runner=runner,
1217
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1219
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1218 1220
         ):
1219 1221
             monkeypatch.setenv('SSH_AUTH_SOCK', os.getcwd())
1220 1222
             _result = runner.invoke(
... ...
@@ -1237,7 +1239,7 @@ contents go here
1237 1239
         with tests.isolated_vault_config(
1238 1240
             monkeypatch=monkeypatch,
1239 1241
             runner=runner,
1240
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1242
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1241 1243
         ):
1242 1244
             tests.make_file_readonly(
1243 1245
                 cli._config_filename(subsystem='vault'),
... ...
@@ -1261,7 +1263,7 @@ contents go here
1261 1263
         with tests.isolated_vault_config(
1262 1264
             monkeypatch=monkeypatch,
1263 1265
             runner=runner,
1264
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1266
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1265 1267
         ):
1266 1268
             custom_error = 'custom error message'
1267 1269
 
... ...
@@ -1495,7 +1497,9 @@ contents go here
1495 1497
         with tests.isolated_vault_config(
1496 1498
             monkeypatch=monkeypatch,
1497 1499
             runner=runner,
1498
-            config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1500
+            vault_config={
1501
+                'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
1502
+            },
1499 1503
         ):
1500 1504
             _result = runner.invoke(
1501 1505
                 cli.derivepassphrase_vault,
... ...
@@ -1517,7 +1521,7 @@ contents go here
1517 1521
         with tests.isolated_vault_config(
1518 1522
             monkeypatch=monkeypatch,
1519 1523
             runner=runner,
1520
-            config={'global': {'phrase': 'abc'}, 'services': {}},
1524
+            vault_config={'global': {'phrase': 'abc'}, 'services': {}},
1521 1525
         ):
1522 1526
             monkeypatch.setenv(
1523 1527
                 'SSH_AUTH_SOCK', "the value doesn't even matter"
... ...
@@ -1559,7 +1563,7 @@ class TestCLIUtils:
1559 1563
     ) -> None:
1560 1564
         runner = click.testing.CliRunner()
1561 1565
         with tests.isolated_vault_config(
1562
-            monkeypatch=monkeypatch, runner=runner, config=config
1566
+            monkeypatch=monkeypatch, runner=runner, vault_config=config
1563 1567
         ):
1564 1568
             config_filename = cli._config_filename(subsystem='vault')
1565 1569
             with open(config_filename, encoding='UTF-8') as fileobj:
... ...
@@ -1575,7 +1579,7 @@ class TestCLIUtils:
1575 1579
         with contextlib.ExitStack() as stack:
1576 1580
             stack.enter_context(
1577 1581
                 tests.isolated_vault_config(
1578
-                    monkeypatch=monkeypatch, runner=runner, config={}
1582
+                    monkeypatch=monkeypatch, runner=runner, vault_config={}
1579 1583
                 )
1580 1584
             )
1581 1585
             stack.enter_context(
... ...
@@ -1862,7 +1866,9 @@ Boo.
1862 1866
         runner = click.testing.CliRunner(mix_stderr=False)
1863 1867
         for start_config in [config, result_config]:
1864 1868
             with tests.isolated_vault_config(
1865
-                monkeypatch=monkeypatch, runner=runner, config=start_config
1869
+                monkeypatch=monkeypatch,
1870
+                runner=runner,
1871
+                vault_config=start_config,
1866 1872
             ):
1867 1873
                 _result = runner.invoke(
1868 1874
                     cli.derivepassphrase_vault,
... ...
@@ -2424,7 +2430,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
2424 2430
             tests.isolated_vault_config(
2425 2431
                 monkeypatch=self.monkeypatch,
2426 2432
                 runner=self.runner,
2427
-                config={'services': {}},
2433
+                vault_config={'services': {}},
2428 2434
             )
2429 2435
         )
2430 2436
 
2431 2437