Move typing classes into separate module `derivepassphrase.typing`
Marco Ricci

Marco Ricci commited on 2024-06-22 21:19:30
Zeige 2 geänderte Dateien mit 184 Einfügungen und 0 Löschungen.


In particular, include type guard functions here as well.
... ...
@@ -0,0 +1,133 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Common typing declarations for the parent module.
6
+
7
+"""
8
+
9
+from __future__ import annotations
10
+
11
+from typing import Any, NotRequired, Required, TypedDict, TypeGuard
12
+
13
+import derivepassphrase
14
+
15
+__author__ = derivepassphrase.__author__
16
+__version__ = derivepassphrase.__version__
17
+
18
+class VaultConfigGlobalSettings(TypedDict, total=False):
19
+    r"""Configuration for vault: global settings.
20
+
21
+    Attributes:
22
+        key:
23
+            The base64-encoded ssh public key to use, overriding the
24
+            master passphrase. Optional.
25
+        phrase:
26
+            The master passphrase. Optional.
27
+
28
+    """
29
+    key: NotRequired[str]
30
+    phrase: NotRequired[str]
31
+
32
+
33
+class VaultConfigServicesSettings(VaultConfigGlobalSettings, total=False):
34
+    r"""Configuration for vault: services settings.
35
+
36
+    Attributes:
37
+        notes:
38
+            Optional notes for this service, to display to the user when
39
+            generating the passphrase.
40
+        length:
41
+            Desired passphrase length.
42
+        repeat:
43
+            The maximum number of immediate character repetitions
44
+            allowed in the passphrase.  Disabled if set to 0.
45
+        lower:
46
+            Optional constraint on ASCII lowercase characters.  If
47
+            positive, include this many lowercase characters
48
+            somewhere in the passphrase.  If 0, avoid lowercase
49
+            characters altogether.
50
+        upper:
51
+            Same as `lower`, but for ASCII uppercase characters.
52
+        number:
53
+            Same as `lower`, but for ASCII digits.
54
+        space:
55
+            Same as `lower`, but for the space character.
56
+        dash:
57
+            Same as `lower`, but for the hyphen-minus and underscore
58
+            characters.
59
+        symbol:
60
+            Same as `lower`, but for all other hitherto unlisted
61
+            ASCII printable characters (except backquote).
62
+
63
+    """
64
+    notes: NotRequired[str]
65
+    length: NotRequired[int]
66
+    repeat: NotRequired[int]
67
+    lower: NotRequired[int]
68
+    upper: NotRequired[int]
69
+    number: NotRequired[int]
70
+    space: NotRequired[int]
71
+    dash: NotRequired[int]
72
+    symbol: NotRequired[int]
73
+
74
+
75
+_VaultConfig = TypedDict('_VaultConfig',
76
+                         {'global': NotRequired[VaultConfigGlobalSettings]},
77
+                         total=False)
78
+class VaultConfig(TypedDict, _VaultConfig, total=False):
79
+    r"""Configuration for vault.
80
+
81
+    Usually stored as JSON.
82
+
83
+    Attributes:
84
+        global (NotRequired[VaultConfigGlobalSettings]):
85
+            Global settings.
86
+        services (Required[dict[str, VaultConfigServicesSettings]]):
87
+            Service-specific settings.
88
+
89
+    """
90
+    services: Required[dict[str, VaultConfigServicesSettings]]
91
+
92
+def is_vault_config(obj: Any) -> TypeGuard[VaultConfig]:
93
+    """Check if `obj` is a valid vault config, according to typing.
94
+
95
+    Args:
96
+        obj: The object to test.
97
+
98
+    Returns:
99
+        True if this is a vault config, false otherwise.
100
+
101
+    """
102
+    if not isinstance(obj, dict):
103
+        return False
104
+    if 'global' in obj:
105
+        o_global = obj['global']
106
+        if not isinstance(o_global, dict):
107
+            return False
108
+        for key in ('key', 'phrase'):
109
+            if key in o_global and not isinstance(o_global[key], str):
110
+                return False
111
+        if 'key' in o_global and 'phrase' in o_global:
112
+            return False
113
+    if not isinstance(obj.get('services'), dict):
114
+        return False
115
+    for sv_name, service in obj['services'].items():
116
+        if not isinstance(sv_name, str):
117
+            return False
118
+        if not isinstance(service, dict):
119
+            return False
120
+        for key, value in service.items():
121
+            match key:
122
+                case 'notes' | 'phrase' | 'key':
123
+                    if not isinstance(value, str):
124
+                        return False
125
+                case 'length':
126
+                    if not isinstance(value, int) or value < 1:
127
+                        return False
128
+                case _:
129
+                    if not isinstance(value, int) or value < 0:
130
+                        return False
131
+        if 'key' in service and 'phrase' in service:
132
+            return False
133
+    return True
... ...
@@ -0,0 +1,51 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+from __future__ import annotations
6
+
7
+from typing import Any, cast, TYPE_CHECKING, NamedTuple
8
+
9
+import derivepassphrase as dpp
10
+import pytest
11
+
12
+@pytest.mark.parametrize(['obj', 'comment'], [
13
+    (None, 'not a dict'),
14
+    ({}, 'missing required keys'),
15
+    ({'global': None, 'services': {}}, 'bad config value: global'),
16
+    ({'global': {'key': 123}, 'services': {}},
17
+     'bad config value: global.key'),
18
+    ({'global': {'phrase': 'abc', 'key': '...'}, 'services': {}},
19
+     'incompatible config values: global.key and global.phrase'),
20
+    ({'services': None}, 'bad config value: services'),
21
+    ({'services': {2: {}}}, 'bad config value: services."2"'),
22
+    ({'services': {'2': 2}}, 'bad config value: services."2"'),
23
+    ({'services': {'sv': {'notes': False}}},
24
+     'bad config value: services.sv.notes'),
25
+    ({'services': {'sv': {'notes': 'blah blah blah'}}}, ''),
26
+    ({'services': {'sv': {'length': '200'}}},
27
+     'bad config value: services.sv.length'),
28
+    ({'services': {'sv': {'length': 0.5}}},
29
+     'bad config value: services.sv.length'),
30
+    ({'services': {'sv': {'length': -10}}},
31
+     'bad config value: services.sv.length'),
32
+    ({'services': {'sv': {'upper': -10}}},
33
+     'bad config value: services.sv.upper'),
34
+    ({'global': {'phrase': 'my secret phrase'},
35
+      'services': {'sv': {'length': 10}}},
36
+     ''),
37
+    ({'services': {'sv': {'length': 10, 'phrase': '...'}}}, ''),
38
+    ({'services': {'sv': {'length': 10, 'key': '...'}}}, ''),
39
+    ({'services': {'sv': {'upper': 10, 'key': '...'}}}, ''),
40
+    ({'services': {'sv': {'phrase': 'abc', 'key': '...'}}},
41
+     'incompatible config values: services.sv.key and services.sv.phrase'),
42
+    ({'global': {'phrase': 'abc'},
43
+      'services': {'sv': {'phrase': 'abc', 'length': 10}}}, ''),
44
+    ({'global': {'key': '...'},
45
+      'services': {'sv': {'phrase': 'abc', 'length': 10}}}, ''),
46
+])
47
+def test_200_is_vault_config(obj: Any, comment: str) -> None:
48
+    assert dpp.types.is_vault_config(obj) == (not comment), (
49
+        'failed to complain about: ' + comment if comment
50
+        else 'failed on valid example'
51
+    )
0 52