Marco Ricci commited on 2024-12-07 08:50:03
Zeige 2 geänderte Dateien mit 146 Einfügungen und 37 Löschungen.
Our previous method of dispatching to subcommands was not tying the supercommand and the subcommand together properly; each command essentially ran in its own isolated context. This lead to unnecessary double-parsing of the command-line arguments, and to `click` becoming confused as to what the full command-line actually was; in particular, the help and usage messages gave a wrong command summary. Instead, we now use custom `click.Group` objects as the supercommands which default to the "vault" subcommand (with a deprecation warning) if no explicit subcommand could be matched, rather than throwing an error; this all happens internally using the normal mechanism for subcommand dispatch. Additionally, if the supercommand callback actually is invoked -- which only happens if the supercommand is given without any arguments -- we emit a deprecation warning and dispatch manually to the "vault" subcommand using the same code path as the `click` implementation would otherwise do. We assert that the old and new code paths work via new tests as necessary, particularly for the "supercommand without arguments" case. This implementation necessarily messes with some `click` internals, duplicating the code as it currently stands. Being a non-public API, there is of course no guarantee that this will work with future versions of `click`. However, we use this solely to implement a compatibility interface, which should vanish with `derivepassphrase` v1.0 anyway. The alternative -- rewriting `derivepassphrase` for another command-line framework, or rolling our own command-line interface from scratch -- seems even more expensive/wasteful than this solution.
... | ... |
@@ -61,7 +61,69 @@ _EMPTY_SELECTION = 'Empty selection' |
61 | 61 |
# ========= |
62 | 62 |
|
63 | 63 |
|
64 |
-@click.command( |
|
64 |
+class _DefaultToVaultGroup(click.Group): |
|
65 |
+ """A helper class to implement the default-to-"vault"-subcommand behavior. |
|
66 |
+ |
|
67 |
+ Modifies internal [`click.MultiCommand`][] methods, and thus is both |
|
68 |
+ an implementation detail and a kludge. |
|
69 |
+ |
|
70 |
+ """ |
|
71 |
+ |
|
72 |
+ def resolve_command( |
|
73 |
+ self, ctx: click.Context, args: list[str] |
|
74 |
+ ) -> tuple[str | None, click.Command | None, list[str]]: |
|
75 |
+ """Resolve a command, but default to "vault" instead of erroring out. |
|
76 |
+ |
|
77 |
+ Based on code from click 8.1, which appears to be essentially |
|
78 |
+ untouched since at least click 3.2. |
|
79 |
+ |
|
80 |
+ """ |
|
81 |
+ cmd_name = click.utils.make_str(args[0]) |
|
82 |
+ |
|
83 |
+ # ORIGINAL COMMENT |
|
84 |
+ # Get the command |
|
85 |
+ cmd = self.get_command(ctx, cmd_name) |
|
86 |
+ |
|
87 |
+ # ORIGINAL COMMENT |
|
88 |
+ # If we can't find the command but there is a normalization |
|
89 |
+ # function available, we try with that one. |
|
90 |
+ if ( # pragma: no cover |
|
91 |
+ cmd is None and ctx.token_normalize_func is not None |
|
92 |
+ ): |
|
93 |
+ cmd_name = ctx.token_normalize_func(cmd_name) |
|
94 |
+ cmd = self.get_command(ctx, cmd_name) |
|
95 |
+ |
|
96 |
+ # ORIGINAL COMMENT |
|
97 |
+ # If we don't find the command we want to show an error message |
|
98 |
+ # to the user that it was not provided. However, there is |
|
99 |
+ # something else we should do: if the first argument looks like |
|
100 |
+ # an option we want to kick off parsing again for arguments to |
|
101 |
+ # resolve things like --help which now should go to the main |
|
102 |
+ # place. |
|
103 |
+ if cmd is None and not ctx.resilient_parsing: |
|
104 |
+ if click.parser.split_opt(cmd_name)[0]: |
|
105 |
+ self.parse_args(ctx, ctx.args) |
|
106 |
+ # Instead of calling ctx.fail here, default to "vault", and |
|
107 |
+ # issue a deprecation warning. |
|
108 |
+ click.echo( |
|
109 |
+ ( |
|
110 |
+ f'{PROG_NAME}: Deprecation warning: A subcommand will be ' |
|
111 |
+ f'required in v1.0. See --help for available subcommands.' |
|
112 |
+ ), |
|
113 |
+ err=True, |
|
114 |
+ ) |
|
115 |
+ click.echo( |
|
116 |
+ f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".', |
|
117 |
+ err=True, |
|
118 |
+ ) |
|
119 |
+ cmd_name = 'vault' |
|
120 |
+ cmd = self.get_command(ctx, cmd_name) |
|
121 |
+ assert cmd is not None, 'Mandatory subcommand "vault" missing!' |
|
122 |
+ args = [cmd_name, *args] |
|
123 |
+ return cmd_name if cmd else None, cmd, args[1:] # noqa: DOC201 |
|
124 |
+ |
|
125 |
+ |
|
126 |
+@click.group( |
|
65 | 127 |
context_settings={ |
66 | 128 |
'help_option_names': ['-h', '--help'], |
67 | 129 |
'ignore_unknown_options': True, |
... | ... |
@@ -73,13 +135,12 @@ _EMPTY_SELECTION = 'Empty selection' |
73 | 135 |
`~/.derivepassphrase` on UNIX-like systems and |
74 | 136 |
`C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows. |
75 | 137 |
""", |
138 |
+ invoke_without_command=True, |
|
139 |
+ cls=_DefaultToVaultGroup, |
|
76 | 140 |
) |
77 | 141 |
@click.version_option(version=dpp.__version__, prog_name=PROG_NAME) |
78 |
-@click.argument('subcommand_args', nargs=-1, type=click.UNPROCESSED) |
|
79 |
-def derivepassphrase( |
|
80 |
- *, |
|
81 |
- subcommand_args: list[str], |
|
82 |
-) -> None: |
|
142 |
+@click.pass_context |
|
143 |
+def derivepassphrase(ctx: click.Context, /) -> None: |
|
83 | 144 |
"""Derive a strong passphrase, deterministically, from a master secret. |
84 | 145 |
|
85 | 146 |
Using a master secret, derive a passphrase for a named service, |
... | ... |
@@ -109,13 +170,7 @@ def derivepassphrase( |
109 | 170 |
[CLICK]: https://pypi.org/package/click/ |
110 | 171 |
|
111 | 172 |
""" # noqa: D301 |
112 |
- if subcommand_args and subcommand_args[0] == 'export': |
|
113 |
- return derivepassphrase_export.main( |
|
114 |
- args=subcommand_args[1:], |
|
115 |
- prog_name=f'{PROG_NAME} export', |
|
116 |
- standalone_mode=False, |
|
117 |
- ) |
|
118 |
- if not (subcommand_args and subcommand_args[0] == 'vault'): |
|
173 |
+ if ctx.invoked_subcommand is None: |
|
119 | 174 |
click.echo( |
120 | 175 |
( |
121 | 176 |
f'{PROG_NAME}: Deprecation warning: A subcommand will be ' |
... | ... |
@@ -127,32 +182,33 @@ def derivepassphrase( |
127 | 182 |
f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".', |
128 | 183 |
err=True, |
129 | 184 |
) |
130 |
- else: |
|
131 |
- subcommand_args = subcommand_args[1:] |
|
132 |
- return derivepassphrase_vault.main( |
|
133 |
- args=subcommand_args, |
|
134 |
- prog_name=f'{PROG_NAME} vault', |
|
135 |
- standalone_mode=False, |
|
185 |
+ # See definition of click.Group.invoke, non-chained case. |
|
186 |
+ with ctx: |
|
187 |
+ sub_ctx = derivepassphrase_vault.make_context( |
|
188 |
+ 'vault', ctx.args, parent=ctx |
|
136 | 189 |
) |
190 |
+ with sub_ctx: |
|
191 |
+ return derivepassphrase_vault.invoke(sub_ctx) |
|
192 |
+ return None |
|
137 | 193 |
|
138 | 194 |
|
139 | 195 |
# Exporter |
140 | 196 |
# ======== |
141 | 197 |
|
142 | 198 |
|
143 |
-@click.command( |
|
199 |
+@derivepassphrase.group( |
|
200 |
+ 'export', |
|
144 | 201 |
context_settings={ |
145 | 202 |
'help_option_names': ['-h', '--help'], |
146 | 203 |
'ignore_unknown_options': True, |
147 | 204 |
'allow_interspersed_args': False, |
148 |
- } |
|
205 |
+ }, |
|
206 |
+ invoke_without_command=True, |
|
207 |
+ cls=_DefaultToVaultGroup, |
|
149 | 208 |
) |
150 | 209 |
@click.version_option(version=dpp.__version__, prog_name=PROG_NAME) |
151 |
-@click.argument('subcommand_args', nargs=-1, type=click.UNPROCESSED) |
|
152 |
-def derivepassphrase_export( |
|
153 |
- *, |
|
154 |
- subcommand_args: list[str], |
|
155 |
-) -> None: |
|
210 |
+@click.pass_context |
|
211 |
+def derivepassphrase_export(ctx: click.Context, /) -> None: |
|
156 | 212 |
"""Export a foreign configuration to standard output. |
157 | 213 |
|
158 | 214 |
Read a foreign system configuration, extract all information from |
... | ... |
@@ -174,7 +230,7 @@ def derivepassphrase_export( |
174 | 230 |
[CLICK]: https://pypi.org/package/click/ |
175 | 231 |
|
176 | 232 |
""" # noqa: D301 |
177 |
- if not (subcommand_args and subcommand_args[0] == 'vault'): |
|
233 |
+ if ctx.invoked_subcommand is None: |
|
178 | 234 |
click.echo( |
179 | 235 |
( |
180 | 236 |
f'{PROG_NAME}: Deprecation warning: A subcommand will be ' |
... | ... |
@@ -186,13 +242,17 @@ def derivepassphrase_export( |
186 | 242 |
f'{PROG_NAME}: Warning: Defaulting to subcommand "vault".', |
187 | 243 |
err=True, |
188 | 244 |
) |
189 |
- else: |
|
190 |
- subcommand_args = subcommand_args[1:] |
|
191 |
- return derivepassphrase_export_vault.main( |
|
192 |
- args=subcommand_args, |
|
193 |
- prog_name=f'{PROG_NAME} export vault', |
|
194 |
- standalone_mode=False, |
|
245 |
+ # See definition of click.Group.invoke, non-chained case. |
|
246 |
+ with ctx: |
|
247 |
+ sub_ctx = derivepassphrase_export_vault.make_context( |
|
248 |
+ 'vault', ctx.args, parent=ctx |
|
195 | 249 |
) |
250 |
+ # Constructing the subcontext above will usually already |
|
251 |
+ # lead to a click.UsageError, so this block typically won't |
|
252 |
+ # actually be called. |
|
253 |
+ with sub_ctx: # pragma: no cover |
|
254 |
+ return derivepassphrase_export_vault.invoke(sub_ctx) |
|
255 |
+ return None |
|
196 | 256 |
|
197 | 257 |
|
198 | 258 |
def _load_data( |
... | ... |
@@ -234,7 +294,8 @@ def _load_data( |
234 | 294 |
assert_never(fmt) |
235 | 295 |
|
236 | 296 |
|
237 |
-@click.command( |
|
297 |
+@derivepassphrase_export.command( |
|
298 |
+ 'vault', |
|
238 | 299 |
context_settings={'help_option_names': ['-h', '--help']}, |
239 | 300 |
) |
240 | 301 |
@click.option( |
... | ... |
@@ -919,9 +980,8 @@ DEFAULT_NOTES_TEMPLATE = """\ |
919 | 980 |
DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -' |
920 | 981 |
|
921 | 982 |
|
922 |
-@click.command( |
|
923 |
- # 'vault', |
|
924 |
- # help="derivation scheme compatible with James Coglan's vault(1)", |
|
983 |
+@derivepassphrase.command( |
|
984 |
+ 'vault', |
|
925 | 985 |
context_settings={'help_option_names': ['-h', '--help']}, |
926 | 986 |
cls=CommandWithHelpGroups, |
927 | 987 |
epilog=r""" |
... | ... |
@@ -2040,6 +2040,30 @@ class TestCLITransition: |
2040 | 2040 |
) |
2041 | 2041 |
assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA |
2042 | 2042 |
|
2043 |
+ def test_201_forward_export_vault_empty_commandline( |
|
2044 |
+ self, |
|
2045 |
+ monkeypatch: pytest.MonkeyPatch, |
|
2046 |
+ caplog: pytest.LogCaptureFixture, |
|
2047 |
+ ) -> None: |
|
2048 |
+ pytest.importorskip('cryptography', minversion='38.0') |
|
2049 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
2050 |
+ with tests.isolated_config( |
|
2051 |
+ monkeypatch=monkeypatch, |
|
2052 |
+ runner=runner, |
|
2053 |
+ ): |
|
2054 |
+ _result = runner.invoke( |
|
2055 |
+ cli.derivepassphrase, |
|
2056 |
+ ['export'], |
|
2057 |
+ ) |
|
2058 |
+ result = tests.ReadableResult.parse(_result) |
|
2059 |
+ assert result.stderr.startswith(f"""\ |
|
2060 |
+{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands. |
|
2061 |
+{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault". |
|
2062 |
+""") # noqa: E501 |
|
2063 |
+ assert result.error_exit( |
|
2064 |
+ error="Missing argument 'PATH'" |
|
2065 |
+ ), 'expected error exit and known error type' |
|
2066 |
+ |
|
2043 | 2067 |
@pytest.mark.parametrize( |
2044 | 2068 |
'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol'] |
2045 | 2069 |
) |
... | ... |
@@ -2074,6 +2098,31 @@ class TestCLITransition: |
2074 | 2098 |
c not in result.output |
2075 | 2099 |
), f'derived password contains forbidden character {c!r}' |
2076 | 2100 |
|
2101 |
+ def test_211_forward_vault_empty_command_line( |
|
2102 |
+ self, |
|
2103 |
+ monkeypatch: pytest.MonkeyPatch, |
|
2104 |
+ caplog: pytest.LogCaptureFixture, |
|
2105 |
+ ) -> None: |
|
2106 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
2107 |
+ with tests.isolated_config( |
|
2108 |
+ monkeypatch=monkeypatch, |
|
2109 |
+ runner=runner, |
|
2110 |
+ ): |
|
2111 |
+ _result = runner.invoke( |
|
2112 |
+ cli.derivepassphrase, |
|
2113 |
+ [], |
|
2114 |
+ input=DUMMY_PASSPHRASE, |
|
2115 |
+ catch_exceptions=False, |
|
2116 |
+ ) |
|
2117 |
+ result = tests.ReadableResult.parse(_result) |
|
2118 |
+ assert result.stderr.startswith(f"""\ |
|
2119 |
+{cli.PROG_NAME}: Deprecation warning: A subcommand will be required in v1.0. See --help for available subcommands. |
|
2120 |
+{cli.PROG_NAME}: Warning: Defaulting to subcommand "vault". |
|
2121 |
+""") # noqa: E501 |
|
2122 |
+ assert result.error_exit( |
|
2123 |
+ error='SERVICE is required' |
|
2124 |
+ ), 'expected error exit and known error type' |
|
2125 |
+ |
|
2077 | 2126 |
def test_300_export_using_old_config_file( |
2078 | 2127 |
self, |
2079 | 2128 |
monkeypatch: pytest.MonkeyPatch, |
2080 | 2129 |