Coverage for tests / test_derivepassphrase_cli / test_all_cli.py: 100.000%

201 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 21:34 +0200

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Tests for the `derivepassphrase` command-line interface: all subcommands. 

6 

7This includes tests for functionality or options common to all 

8subcommands. 

9 

10""" 

11 

12from __future__ import annotations 

13 

14import contextlib 

15import enum 

16import re 

17import types 

18from typing import TYPE_CHECKING 

19 

20import exceptiongroup 

21import pytest 

22from typing_extensions import NamedTuple 

23 

24from derivepassphrase import _types, cli, ssh_agent 

25from derivepassphrase._internals import cli_messages 

26from tests import machinery 

27from tests.machinery import pytest as pytest_machinery 

28 

29if TYPE_CHECKING: 

30 from collections.abc import Generator 

31 

32 

33class VersionOutputData(NamedTuple): 

34 derivation_schemes: dict[str, bool] 

35 foreign_configuration_formats: dict[str, bool] 

36 extras: frozenset[str] 

37 subcommands: frozenset[str] 

38 features: dict[str, bool] 

39 ssh_agent_socket_providers: dict[str, bool] 

40 

41 

42def _label_text(e: cli_messages.Label, /) -> str: 

43 return e.value.singular.rstrip(":") 

44 

45 

46class KnownLineType(str, enum.Enum): 

47 SUPPORTED_FOREIGN_CONFS = _label_text( 

48 cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS 

49 ) 

50 UNAVAILABLE_FOREIGN_CONFS = _label_text( 

51 cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS 

52 ) 

53 SUPPORTED_SCHEMES = _label_text( 

54 cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES 

55 ) 

56 UNAVAILABLE_SCHEMES = _label_text( 

57 cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES 

58 ) 

59 SUPPORTED_SUBCOMMANDS = _label_text( 

60 cli_messages.Label.SUPPORTED_SUBCOMMANDS 

61 ) 

62 SUPPORTED_FEATURES = _label_text(cli_messages.Label.SUPPORTED_FEATURES) 

63 UNAVAILABLE_FEATURES = _label_text(cli_messages.Label.UNAVAILABLE_FEATURES) 

64 SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS = _label_text( 

65 cli_messages.Label.SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS 

66 ) 

67 UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS = _label_text( 

68 cli_messages.Label.UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS 

69 ) 

70 ENABLED_EXTRAS = _label_text(cli_messages.Label.ENABLED_PEP508_EXTRAS) 

71 

72 

73class Parametrize(types.SimpleNamespace): 

74 """Common test parametrizations.""" 

75 

76 EAGER_ARGUMENTS = pytest.mark.parametrize( 

77 "arguments", 

78 [["--help"], ["--version"]], 

79 ids=["help", "version"], 

80 ) 

81 COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize( 

82 ["command", "non_eager_arguments"], 

83 [ 

84 pytest.param( 

85 [], 

86 [], 

87 id="top-nothing", 

88 ), 

89 pytest.param( 

90 [], 

91 ["export"], 

92 id="top-export", 

93 ), 

94 pytest.param( 

95 ["export"], 

96 [], 

97 id="export-nothing", 

98 ), 

99 pytest.param( 

100 ["export"], 

101 ["vault"], 

102 id="export-vault", 

103 ), 

104 pytest.param( 

105 ["export", "vault"], 

106 [], 

107 id="export-vault-nothing", 

108 ), 

109 pytest.param( 

110 ["export", "vault"], 

111 ["--format", "this-format-doesnt-exist"], 

112 id="export-vault-args", 

113 ), 

114 pytest.param( 

115 ["vault"], 

116 [], 

117 id="vault-nothing", 

118 ), 

119 pytest.param( 

120 ["vault"], 

121 ["--export", "./"], 

122 id="vault-args", 

123 ), 

124 ], 

125 ) 

126 HELP_OUTPUT_COMMAND_LINE = pytest.mark.parametrize( 

127 ["command_line", "expected_lines"], 

128 [ 

129 pytest.param( 

130 [], 

131 ["currently implemented subcommands"], 

132 id="derivepassphrase", 

133 ), 

134 pytest.param( 

135 ["export"], 

136 ["only available subcommand"], 

137 id="derivepassphrase-export", 

138 ), 

139 pytest.param( 

140 ["export", "vault"], 

141 ["Export a vault-native configuration"], 

142 id="derivepassphrase-export-vault", 

143 ), 

144 pytest.param( 

145 ["vault"], 

146 [ 

147 "Passphrase generation:", 

148 "Use $VISUAL or $EDITOR to configure", 

149 ], 

150 id="derivepassphrase-vault", 

151 ), 

152 ], 

153 ) 

154 COLORFUL_COMMAND_INPUT = pytest.mark.parametrize( 

155 ["command_line", "input"], 

156 [ 

157 ( 

158 ["vault", "--import", "-"], 

159 '{"services": {"": {"length": 20}}}', 

160 ), 

161 ], 

162 ids=["cmd"], 

163 ) 

164 ISATTY = pytest.mark.parametrize( 

165 "isatty", 

166 [False, True], 

167 ids=["notty", "tty"], 

168 ) 

169 MASK_PROG_NAME = pytest.mark.parametrize( 

170 "mask_prog_name", 

171 [False, True], 

172 ids=["clear_prog_name", "masked_prog_name"], 

173 ) 

174 MASK_VERSION = pytest.mark.parametrize( 

175 "mask_version", [False, True], ids=["clear_version", "masked_version"] 

176 ) 

177 VERSION_OUTPUT_DATA = pytest.mark.parametrize( 

178 ["version_output", "prog_name", "version", "expected_parse"], 

179 [ 

180 pytest.param( 

181 """\ 

182derivepassphrase 0.4.0 

183Using cryptography 44.0.0 

184 

185Supported foreign configuration formats: vault storeroom, vault v0.2, 

186 vault v0.3. 

187PEP 508 extras: export. 

188""", 

189 "derivepassphrase", 

190 "0.4.0", 

191 VersionOutputData( 

192 derivation_schemes={}, 

193 foreign_configuration_formats={ 

194 "vault storeroom": True, 

195 "vault v0.2": True, 

196 "vault v0.3": True, 

197 }, 

198 subcommands=frozenset(), 

199 features={}, 

200 extras=frozenset({"export"}), 

201 ssh_agent_socket_providers={}, 

202 ), 

203 id="derivepassphrase-0.4.0-export", 

204 ), 

205 pytest.param( 

206 """\ 

207derivepassphrase 0.5 

208 

209Supported derivation schemes: vault. 

210Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. 

211Supported subcommands: export, vault. 

212No PEP 508 extras are active. 

213""", 

214 "derivepassphrase", 

215 "0.5", 

216 VersionOutputData( 

217 derivation_schemes={"vault": True}, 

218 foreign_configuration_formats={ 

219 "vault storeroom": False, 

220 "vault v0.2": False, 

221 "vault v0.3": False, 

222 }, 

223 subcommands=frozenset({"export", "vault"}), 

224 features={}, 

225 extras=frozenset({}), 

226 ssh_agent_socket_providers={}, 

227 ), 

228 id="derivepassphrase-0.5-plain", 

229 ), 

230 pytest.param( 

231 """\ 

232 

233 

234 

235inventpassphrase -1.3 

236Using not-a-library 7.12 

237Copyright 2025 Nobody. All rights reserved. 

238 

239Supported derivation schemes: nonsense. 

240Known derivation schemes: divination, /dev/random, 

241 geiger counter, 

242 crossword solver. 

243Supported foreign configuration formats: derivepassphrase, nonsense. 

244Known foreign configuration formats: divination v3.141592, 

245 /dev/random. 

246Supported subcommands: delete-all-files, dump-core. 

247Supported features: delete-while-open. 

248Known features: backups-are-nice-to-have. 

249Supported SSH agent socket providers: agents-of-shield. 

250Known SSH agent socket providers: agent-smith. 

251PEP 508 extras: annoying-popups, delete-all-files, 

252 dump-core-depending-on-the-phase-of-the-moon. 

253 

254 

255 

256""", 

257 "inventpassphrase", 

258 "-1.3", 

259 VersionOutputData( 

260 derivation_schemes={ 

261 "nonsense": True, 

262 "divination": False, 

263 "/dev/random": False, 

264 "geiger counter": False, 

265 "crossword solver": False, 

266 }, 

267 foreign_configuration_formats={ 

268 "derivepassphrase": True, 

269 "nonsense": True, 

270 "divination v3.141592": False, 

271 "/dev/random": False, 

272 }, 

273 subcommands=frozenset({"delete-all-files", "dump-core"}), 

274 features={ 

275 "delete-while-open": True, 

276 "backups-are-nice-to-have": False, 

277 }, 

278 extras=frozenset({ 

279 "annoying-popups", 

280 "delete-all-files", 

281 "dump-core-depending-on-the-phase-of-the-moon", 

282 }), 

283 ssh_agent_socket_providers={ 

284 "agents-of-shield": True, 

285 "agent-smith": False, 

286 }, 

287 ), 

288 id="inventpassphrase", 

289 ), 

290 pytest.param( 

291 """\ 

292derivepassphrase 1.0 

293Using wishful-thinking 2.0. 

294 

295Supported derivation schemes: spectre ({aliases!s} master-password, mpw), 

296 vault. 

297Supported subcommands: export, spectre ({aliases!s} master-password, mpw), 

298 vault. 

299""".format( 

300 aliases=cli_messages.TranslatedString( 

301 cli_messages.Label.FEATURE_ITEM_ALIASES 

302 ) 

303 ), 

304 "derivepassphrase", 

305 "1.0", 

306 VersionOutputData( 

307 derivation_schemes={ 

308 "master-password": True, 

309 "mpw": True, 

310 "spectre": True, 

311 "vault": True, 

312 }, 

313 foreign_configuration_formats={}, 

314 subcommands=frozenset({ 

315 "export", 

316 "master-password", 

317 "mpw", 

318 "spectre", 

319 "vault", 

320 }), 

321 features={}, 

322 extras=frozenset(), 

323 ssh_agent_socket_providers={}, 

324 ), 

325 id="aliases", 

326 ), 

327 ], 

328 ) 

329 """Sample data for [`parse_version_output`][].""" 

330 

331 

332def tokenize_version_output_item_listing( 

333 line: str, 

334 /, 

335 *, 

336 is_alias_annotation: bool = False, 

337) -> Generator[str, None, None]: 

338 """Yield the next feature in a `--version` feature listing. 

339 

340 This is a regular expression-based parser (alluded to in 

341 [`parse_version_output`][]) that yields the next item name or alias 

342 it encounters. (The output is indistinguishable for those two 

343 types.) 

344 

345 We assume that continuation lines have already been reversed, i.e., 

346 that the whole input is on a single line. We further assume that 

347 the listing header has been removed, i.e., we are only processing 

348 the raw list items. 

349 

350 Args: 

351 line: 

352 The input line, normalized as explained above. 

353 is_alias_annotation: 

354 If true, then the input line is contents of an alias 

355 listing, and itself does not support aliases. Otherwise, 

356 aliases are supported. 

357 

358 Yields: 

359 The next item name or alias. There is no way to distinguish 

360 these cases based on output alone. 

361 

362 """ 

363 chunk_re = re.compile( 1cfbea

364 r""" 

365 # the item name 

366 (?P<name>[^,()]+) 

367 # whitespace 

368 (?:[ ]*) 

369 # the terminator 

370 (?:,[ ]*|$) 

371 """ 

372 if is_alias_annotation 

373 else r""" 

374 # the item name 

375 (?P<name>[^,()]+) 

376 # alias list 

377 (?:[ ]+ 

378 # alias marker 

379 \( 

380 {aliases_marker!s} 

381 [ ]+ 

382 # the alias entries 

383 (?P<alias_list>[^()]+) 

384 \) 

385 )? 

386 # the terminator 

387 (?:,[ ]*|\.$) 

388 """.format( 

389 aliases_marker=cli_messages.TranslatedString( 

390 cli_messages.Label.FEATURE_ITEM_ALIASES 

391 ) 

392 ), 

393 re.VERBOSE, 

394 ) 

395 rest = line.strip() 1cfbea

396 while (match := chunk_re.match(rest)) is not None: 1cfbea

397 name = match.group("name") 1cfbea

398 assert name, "item listing tokenizer is inconsistent" 1cfbea

399 assert name.strip() == name, "item listing tokenizer is inconsistent" 1cfbea

400 yield name 1cfbea

401 if not is_alias_annotation and match.group("alias_list"): 1cfbea

402 alias_list = match.group("alias_list") 1ca

403 assert alias_list, "item listing tokenizer is inconsistent" 1ca

404 assert alias_list.strip(), "item listing tokenizer is inconsistent" 1ca

405 assert alias_list.lstrip() == alias_list, ( 1ca

406 "item listing tokenizer is inconsistent" 

407 ) 

408 yield from tokenize_version_output_item_listing( 1ca

409 alias_list.strip(), is_alias_annotation=True 

410 ) 

411 rest = rest.removeprefix(match.group(0)) 1cfbea

412 if rest: # pragma: no cover [defensive] 1cfbea

413 msg = f"Trailing unparsable junk on {line!r}: {rest!r}" 

414 raise ValueError(msg) 

415 

416 

417def parse_version_output( # noqa: C901 

418 version_output: str, 

419 /, 

420 *, 

421 prog_name: str | None = cli_messages.PROG_NAME, 

422 version: str | None = cli_messages.VERSION, 

423) -> VersionOutputData: 

424 r"""Parse the output of the `--version` option. 

425 

426 The version output contains two paragraphs. The first paragraph 

427 details the version number, and the version number of any major 

428 libraries in use. The second paragraph details known and supported 

429 passphrase derivation schemes, foreign configuration formats, 

430 subcommands, SSH agent socket providers and PEP 508 package extras. 

431 

432 For the schemes, formats and socket providers, there is 

433 a "supported" line for supported items, and a "known" line for known 

434 but currently unsupported items (usually because of missing 

435 dependencies), either of which may be empty and thus omitted. For 

436 extras, only active items are shown, and there is a separate message 

437 for the "no extras active" case. Items may be followed by a list of 

438 aliases, explicitly marked as such. Item lists may be spilled 

439 across multiple lines, but only at item boundaries. (The alias list 

440 counts as part of the same item.) The continuation lines are then 

441 indented. 

442 

443 The list of aliases is formatted as `<name> (aliases: <alias1>, 

444 <alias2>)`. Only one level of aliases is supported, and neither 

445 `<name>` nor `<alias>` must contain parentheses. (Brackets and 

446 braces are discouraged, but not expressly forbidden.) 

447 

448 Args: 

449 version_output: 

450 The version output text to parse. 

451 prog_name: 

452 The program name to assert, defaulting to the true program 

453 name, `derivepassphrase`. Set to `None` to disable this 

454 check. 

455 version: 

456 The program version to assert, defaulting to the true 

457 current version of `derivepassphrase`. Set to `None` to 

458 disable this check. 

459 

460 Examples: 

461 See [`Parametrize.VERSION_OUTPUT_DATA`][]. 

462 

463 See also: 

464 * [`tokenize_version_output_item_listing`][] 

465 

466 """ 

467 paragraphs: list[list[str]] = [] 1cfbea

468 paragraph: list[str] = [] 1cfbea

469 for line in version_output.splitlines(keepends=False): 1cfbea

470 if not line.strip(): 1cfbea

471 if paragraph: 1cfbea

472 paragraphs.append(paragraph.copy()) 1cfbea

473 paragraph.clear() 1cfbea

474 elif paragraph and line.lstrip() != line: 1cfbea

475 paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}" 1cbea

476 else: 

477 paragraph.append(line) 1cfbea

478 if paragraph: # pragma: no branch 1cfbea

479 paragraphs.append(paragraph.copy()) 1cfbea

480 paragraph.clear() 1cfbea

481 assert paragraphs, ( 1cfbea

482 f"expected at least one paragraph of version output: {paragraphs!r}" 

483 ) 

484 assert prog_name is None or prog_name in paragraphs[0][0], ( 1cfbea

485 f"first version output line should mention " 

486 f"{prog_name}: {paragraphs[0][0]!r}" 

487 ) 

488 assert version is None or version in paragraphs[0][0], ( 1cfbea

489 f"first version output line should mention the version number " 

490 f"{version}: {paragraphs[0][0]!r}" 

491 ) 

492 schemes: dict[str, bool] = {} 1cfbea

493 formats: dict[str, bool] = {} 1cfbea

494 subcommands: set[str] = set() 1cfbea

495 extras: set[str] = set() 1cfbea

496 features: dict[str, bool] = {} 1cfbea

497 ssh_agent_socket_providers: dict[str, bool] = {} 1cfbea

498 if len(paragraphs) < 2: # pragma: no cover 1cfbea

499 return VersionOutputData( 

500 derivation_schemes=schemes, 

501 foreign_configuration_formats=formats, 

502 subcommands=frozenset(subcommands), 

503 extras=frozenset(extras), 

504 features=features, 

505 ssh_agent_socket_providers=ssh_agent_socket_providers, 

506 ) 

507 for line in paragraphs[1]: 1cfbea

508 line_type, _, value = line.partition(":") 1cfbea

509 if line_type == line: 1cfbea

510 continue 1c

511 for item_ in tokenize_version_output_item_listing(value): 1cfbea

512 item = item_.strip() 1cfbea

513 if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: 1cfbea

514 formats[item] = True 1cb

515 elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: 1cfbea

516 formats[item] = False 1cbe

517 elif line_type == KnownLineType.SUPPORTED_SCHEMES: 1cfbea

518 schemes[item] = True 1cf

519 elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: 1cfbea

520 schemes[item] = False 1c

521 elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: 1cfbea

522 subcommands.add(item) 1cfe

523 elif line_type == KnownLineType.ENABLED_EXTRAS: 1cba

524 extras.add(item) 1cb

525 elif line_type == KnownLineType.SUPPORTED_FEATURES: 1ca

526 features[item] = True 1ca

527 elif line_type == KnownLineType.UNAVAILABLE_FEATURES: 1ca

528 features[item] = False 1c

529 elif ( 1ca

530 line_type == KnownLineType.SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS 

531 ): 

532 ssh_agent_socket_providers[item] = True 1ca

533 elif ( 1ca

534 line_type 

535 == KnownLineType.UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS 

536 ): 

537 ssh_agent_socket_providers[item] = False 1ca

538 else: 

539 raise AssertionError( # noqa: TRY003 

540 f"Unknown version info line type: {line_type!r}" # noqa: EM102 

541 ) 

542 return VersionOutputData( 1cfbea

543 derivation_schemes=schemes, 

544 foreign_configuration_formats=formats, 

545 subcommands=frozenset(subcommands), 

546 extras=frozenset(extras), 

547 features=features, 

548 ssh_agent_socket_providers=ssh_agent_socket_providers, 

549 ) 

550 

551 

552class Test001VersionOutputParser: 

553 """Tests for the `--version` output parser.""" 

554 

555 @Parametrize.MASK_PROG_NAME 

556 @Parametrize.MASK_VERSION 

557 @Parametrize.VERSION_OUTPUT_DATA 

558 def test_parse_version_output( 

559 self, 

560 version_output: str, 

561 prog_name: str | None, 

562 version: str | None, 

563 mask_prog_name: bool, 

564 mask_version: bool, 

565 expected_parse: VersionOutputData, 

566 ) -> None: 

567 """The parsing machinery for expected version output data works.""" 

568 prog_name = None if mask_prog_name else prog_name 1c

569 version = None if mask_version else version 1c

570 assert ( 1c

571 parse_version_output( 

572 version_output, prog_name=prog_name, version=version 

573 ) 

574 == expected_parse 

575 ) 

576 

577 

578class TestHelpOutput: 

579 """Tests for all command-line interfaces' `--help` output.""" 

580 

581 # TODO(the-13th-letter): Do we actually need this? What should we 

582 # check for? 

583 @Parametrize.HELP_OUTPUT_COMMAND_LINE 

584 def test_help_output( 

585 self, 

586 command_line: list[str], 

587 expected_lines: list[str], 

588 ) -> None: 

589 """The respective help text contains certain expected phrases. 

590 

591 TODO: Do we actually need this? What should we check for? 

592 

593 """ 

594 runner = machinery.CliRunner(mix_stderr=False) 1h

595 # TODO(the-13th-letter): Rewrite using parenthesized 

596 # with-statements. 

597 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

598 with contextlib.ExitStack() as stack: 1h

599 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1h

600 stack.enter_context( 1h

601 pytest_machinery.isolated_config( 

602 monkeypatch=monkeypatch, 

603 runner=runner, 

604 ) 

605 ) 

606 result = runner.invoke( 1h

607 cli.derivepassphrase, 

608 [*command_line, "--help"], 

609 catch_exceptions=False, 

610 ) 

611 for line in expected_lines: 1h

612 assert result.clean_exit(empty_stderr=True, output=line), ( 1h

613 "expected clean exit, and known help text" 

614 ) 

615 

616 @Parametrize.COMMAND_NON_EAGER_ARGUMENTS 

617 @Parametrize.EAGER_ARGUMENTS 

618 def test_eager_options( 

619 self, 

620 command: list[str], 

621 arguments: list[str], 

622 non_eager_arguments: list[str], 

623 ) -> None: 

624 """Eager options terminate option and argument processing.""" 

625 runner = machinery.CliRunner(mix_stderr=False) 1i

626 # TODO(the-13th-letter): Rewrite using parenthesized 

627 # with-statements. 

628 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

629 with contextlib.ExitStack() as stack: 1i

630 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1i

631 stack.enter_context( 1i

632 pytest_machinery.isolated_config( 

633 monkeypatch=monkeypatch, 

634 runner=runner, 

635 ) 

636 ) 

637 result = runner.invoke( 1i

638 cli.derivepassphrase, 

639 [*command, *arguments, *non_eager_arguments], 

640 catch_exceptions=False, 

641 ) 

642 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1i

643 

644 @Parametrize.ISATTY 

645 @Parametrize.COLORFUL_COMMAND_INPUT 

646 def test_automatic_color_mode( 

647 self, 

648 isatty: bool, 

649 command_line: list[str], 

650 input: str | None, 

651 ) -> None: 

652 """Auto-detect if color should be used. 

653 

654 (The answer currently is always no. See the 

655 [`conventional-configurable-text-styling` wishlist 

656 entry][WISHLIST_ENTRY].) 

657 

658 [WISHLIST_ENTRY]: https://the13thletter.info/derivepassphrase/0.x/wishlist/conventional-configurable-text-styling/ 

659 

660 """ 

661 color = False 1g

662 runner = machinery.CliRunner(mix_stderr=False) 1g

663 # TODO(the-13th-letter): Rewrite using parenthesized 

664 # with-statements. 

665 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

666 with contextlib.ExitStack() as stack: 1g

667 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1g

668 stack.enter_context( 1g

669 pytest_machinery.isolated_config( 

670 monkeypatch=monkeypatch, 

671 runner=runner, 

672 ) 

673 ) 

674 result = runner.invoke( 1g

675 cli.derivepassphrase, 

676 command_line, 

677 input=input, 

678 catch_exceptions=False, 

679 color=isatty, 

680 ) 

681 assert ( 1g

682 not color 

683 or "\x1b[0m" in result.stderr 

684 or "\x1b[m" in result.stderr 

685 ), "Expected color, but found no ANSI reset sequence" 

686 assert color or "\x1b[" not in result.stderr, ( 1g

687 "Expected no color, but found an ANSI control sequence" 

688 ) 

689 

690 

691class TestVersionOutput: 

692 """Tests for all command-line interfaces' `--version` output.""" 

693 

694 def _test( 

695 self, 

696 command_line: list[str], 

697 ) -> VersionOutputData: 

698 runner = machinery.CliRunner(mix_stderr=False) 1fbea

699 # TODO(the-13th-letter): Rewrite using parenthesized 

700 # with-statements. 

701 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

702 with contextlib.ExitStack() as stack: 1fbea

703 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fbea

704 stack.enter_context( 1fbea

705 pytest_machinery.isolated_config( 

706 monkeypatch=monkeypatch, 

707 runner=runner, 

708 ) 

709 ) 

710 result = runner.invoke( 1fbea

711 cli.derivepassphrase, 

712 [*command_line, "--version"], 

713 catch_exceptions=False, 

714 ) 

715 assert result.clean_exit(empty_stderr=True), "expected clean exit" 1fbea

716 assert result.stdout.strip(), "expected version output" 1fbea

717 return parse_version_output(result.stdout) 1fbea

718 

719 def test_derivepassphrase_version_option_output( 

720 self, 

721 ) -> None: 

722 """The version output states supported features. 

723 

724 The version output is parsed using [`parse_version_output`][]. 

725 Format examples can be found in 

726 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

727 top-level `derivepassphrase` command, the output should contain 

728 the known and supported derivation schemes, and a list of 

729 subcommands. 

730 

731 As a side effect, [`parse_version_output`][] guarantees that the 

732 first line contains both the correct program name as well as the 

733 correct program version number. 

734 

735 """ 

736 version_data = self._test([]) 1f

737 actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) 1f

738 subcommands = set(_types.Subcommand) 1f

739 assert version_data.derivation_schemes == actually_known_schemes 1f

740 assert not version_data.foreign_configuration_formats 1f

741 assert version_data.subcommands == subcommands 1f

742 assert not version_data.features 1f

743 assert not version_data.extras 1f

744 

745 def test_export_version_option_output( 

746 self, 

747 ) -> None: 

748 """The version output states supported features. 

749 

750 The version output is parsed using [`parse_version_output`][]. 

751 Format examples can be found in 

752 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

753 `export` command, the output should contain the known foreign 

754 configuration formats (but not marked as supported), and a list 

755 of subcommands. 

756 

757 As a side effect, [`parse_version_output`][] guarantees that the 

758 first line contains both the correct program name as well as the 

759 correct program version number. 

760 

761 """ 

762 version_data = self._test(["export"]) 1e

763 actually_known_formats: dict[str, bool] = { 1e

764 _types.ForeignConfigurationFormat.VAULT_STOREROOM: False, 

765 _types.ForeignConfigurationFormat.VAULT_V02: False, 

766 _types.ForeignConfigurationFormat.VAULT_V03: False, 

767 } 

768 subcommands = set(_types.ExportSubcommand) 1e

769 assert not version_data.derivation_schemes 1e

770 assert ( 1e

771 version_data.foreign_configuration_formats 

772 == actually_known_formats 

773 ) 

774 assert version_data.subcommands == subcommands 1e

775 assert not version_data.features 1e

776 assert not version_data.extras 1e

777 

778 def test_export_vault_version_option_output( 

779 self, 

780 ) -> None: 

781 """The version output states supported features. 

782 

783 The version output is parsed using [`parse_version_output`][]. 

784 Format examples can be found in 

785 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

786 `export vault` subcommand, the output should contain the 

787 vault-specific subset of the known or supported foreign 

788 configuration formats, and a list of available PEP 508 extras. 

789 

790 As a side effect, [`parse_version_output`][] guarantees that the 

791 first line contains both the correct program name as well as the 

792 correct program version number. 

793 

794 """ 

795 version_data = self._test(["export", "vault"]) 1b

796 actually_known_formats: dict[str, bool] = {} 1b

797 actually_enabled_extras: set[str] = set() 1b

798 with contextlib.suppress(ModuleNotFoundError): 1b

799 from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 1b

800 

801 actually_known_formats.update({ 1b

802 _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED, 

803 _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED, 

804 _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED, 

805 }) 

806 with contextlib.suppress(ModuleNotFoundError): 1b

807 import cryptography # noqa: F401,PLC0415 1b

808 

809 actually_enabled_extras.add(_types.PEP508Extra.EXPORT) 1b

810 assert not version_data.derivation_schemes 1b

811 assert ( 1b

812 version_data.foreign_configuration_formats 

813 == actually_known_formats 

814 ) 

815 assert not version_data.subcommands 1b

816 assert not version_data.features 1b

817 assert version_data.extras == actually_enabled_extras 1b

818 

819 def test_vault_version_option_output( 

820 self, 

821 ) -> None: 

822 """The version output states supported features. 

823 

824 The version output is parsed using [`parse_version_output`][]. 

825 Format examples can be found in 

826 [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the 

827 vault command, the output should not contain anything beyond the 

828 first paragraph. 

829 

830 As a side effect, [`parse_version_output`][] guarantees that the 

831 first line contains both the correct program name as well as the 

832 correct program version number. 

833 

834 """ 

835 version_data = self._test(["vault"]) 1a

836 ssh_key_supported = True 1a

837 

838 def react_to_notimplementederror( 1a

839 _exc: BaseException, 1a

840 ) -> None: # pragma: no cover[unused] 1a

841 nonlocal ssh_key_supported 

842 ssh_key_supported = False 

843 

844 with exceptiongroup.catch({ # noqa: SIM117 1a

845 NotImplementedError: react_to_notimplementederror, 

846 Exception: lambda *_args: None, 

847 }): 

848 with ssh_agent.SSHAgentClient.ensure_agent_subcontext(): 1a

849 pass 1a

850 features: dict[str, bool] = { 1a

851 _types.Feature.SSH_KEY: ssh_key_supported, 

852 } 

853 assert not version_data.derivation_schemes 1a

854 assert not version_data.foreign_configuration_formats 1a

855 assert not version_data.subcommands 1a

856 assert version_data.features == features 1a

857 assert not version_data.extras 1a