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

143 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: shell completion.""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import json 

11import types 

12from typing import TYPE_CHECKING 

13 

14import click.shell_completion 

15import hypothesis 

16import pytest 

17from typing_extensions import Any 

18 

19from derivepassphrase import _types, cli 

20from derivepassphrase._internals import cli_helpers 

21from derivepassphrase._internals import ( 

22 cli_machinery as cli_machinery, # noqa: PLC0414 

23) 

24from tests import data, machinery 

25from tests.machinery import hypothesis as hypothesis_machinery 

26from tests.machinery import pytest as pytest_machinery 

27 

28if TYPE_CHECKING: 

29 from collections.abc import Callable, Sequence 

30 from collections.abc import Set as AbstractSet 

31 from typing import NoReturn 

32 

33 from typing_extensions import Literal 

34 

35DUMMY_SERVICE = data.DUMMY_SERVICE 

36DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS 

37 

38 

39def bash_format(item: click.shell_completion.CompletionItem) -> str: 

40 """A formatter for `bash`-style shell completion items. 

41 

42 The format is `type,value`, and is dictated by 

43 [`click`](https://pypi.org/project/click/). 

44 

45 """ 

46 type, value = ( # noqa: A001 1b

47 item.type, 

48 item.value, 

49 ) 

50 return f"{type},{value}" 1b

51 

52 

53def fish_format(item: click.shell_completion.CompletionItem) -> str: 

54 r"""A formatter for `fish`-style shell completion items. 

55 

56 The format is `type,value<tab>help`, and is dictated by 

57 [`click`](https://pypi.org/project/click/). 

58 

59 """ 

60 type, value, help = ( # noqa: A001 1b

61 item.type, 

62 item.value, 

63 item.help, 

64 ) 

65 return f"{type},{value}\t{help}" if help else f"{type},{value}" 1b

66 

67 

68def zsh_format(item: click.shell_completion.CompletionItem) -> str: 

69 r"""A formatter for `zsh`-style shell completion items. 

70 

71 The format is `type<newline>value<newline>help<newline>`, and is 

72 dictated by [`click`](https://pypi.org/project/click/). Upstream 

73 `click` up to and including v8.2.1 does not deal with colons in the 

74 value correctly when the help text is non-degenerate. Our formatter 

75 here does, provided the upstream `zsh` completion script is used; 

76 see the [`cli_machinery.ZshComplete`][] class. 

77 

78 """ 

79 empty_help = "_" 1b

80 help_, value = ( 1b

81 (item.help, item.value.replace(":", r"\:")) 

82 if item.help and item.help == empty_help 

83 else (empty_help, item.value) 

84 ) 

85 return f"{item.type}\n{value}\n{help_}" 1b

86 

87 

88def completion_item( 

89 item: str | click.shell_completion.CompletionItem, 

90) -> click.shell_completion.CompletionItem: 

91 """Convert a string to a completion item, if necessary.""" 

92 return ( 1b

93 click.shell_completion.CompletionItem(item, type="plain") 

94 if isinstance(item, str) 

95 else item 

96 ) 

97 

98 

99def assertable_item( 

100 item: str | click.shell_completion.CompletionItem, 

101) -> tuple[str, Any, str | None]: 

102 """Convert a completion item into a pretty-printable item. 

103 

104 Intended to make completion items introspectable in pytest's 

105 `assert` output. 

106 

107 """ 

108 item = completion_item(item) 1b

109 return (item.type, item.value, item.help) 1b

110 

111 

112class Parametrize(types.SimpleNamespace): 

113 """Common test parametrizations.""" 

114 

115 COMPLETABLE_PATH_ARGUMENT = pytest.mark.parametrize( 

116 "command_prefix", 

117 [ 

118 pytest.param( 

119 ("export", "vault"), 

120 id="derivepassphrase-export-vault", 

121 ), 

122 pytest.param( 

123 ("vault", "--export"), 

124 id="derivepassphrase-vault--export", 

125 ), 

126 pytest.param( 

127 ("vault", "--import"), 

128 id="derivepassphrase-vault--import", 

129 ), 

130 ], 

131 ) 

132 COMPLETABLE_OPTIONS = pytest.mark.parametrize( 

133 ["command_prefix", "incomplete", "completions"], 

134 [ 

135 pytest.param( 

136 (), 

137 "-", 

138 frozenset({ 

139 "--help", 

140 "-h", 

141 "--version", 

142 "--debug", 

143 "--verbose", 

144 "-v", 

145 "--quiet", 

146 "-q", 

147 }), 

148 id="derivepassphrase", 

149 ), 

150 pytest.param( 

151 ("export",), 

152 "-", 

153 frozenset({ 

154 "--help", 

155 "-h", 

156 "--version", 

157 "--debug", 

158 "--verbose", 

159 "-v", 

160 "--quiet", 

161 "-q", 

162 }), 

163 id="derivepassphrase-export", 

164 ), 

165 pytest.param( 

166 ("export", "vault"), 

167 "-", 

168 frozenset({ 

169 "--help", 

170 "-h", 

171 "--version", 

172 "--debug", 

173 "--verbose", 

174 "-v", 

175 "--quiet", 

176 "-q", 

177 "--format", 

178 "-f", 

179 "--key", 

180 "-k", 

181 }), 

182 id="derivepassphrase-export-vault", 

183 ), 

184 pytest.param( 

185 ("vault",), 

186 "-", 

187 frozenset({ 

188 "--help", 

189 "-h", 

190 "--version", 

191 "--debug", 

192 "--verbose", 

193 "-v", 

194 "--quiet", 

195 "-q", 

196 "--phrase", 

197 "-p", 

198 "--key", 

199 "-k", 

200 "--length", 

201 "-l", 

202 "--repeat", 

203 "-r", 

204 "--upper", 

205 "--lower", 

206 "--number", 

207 "--space", 

208 "--dash", 

209 "--symbol", 

210 "--config", 

211 "-c", 

212 "--notes", 

213 "-n", 

214 "--delete", 

215 "-x", 

216 "--delete-globals", 

217 "--clear", 

218 "-X", 

219 "--export", 

220 "-e", 

221 "--import", 

222 "-i", 

223 "--overwrite-existing", 

224 "--merge-existing", 

225 "--unset", 

226 "--export-as", 

227 "--modern-editor-interface", 

228 "--vault-legacy-editor-interface", 

229 "--print-notes-before", 

230 "--print-notes-after", 

231 "--ssh-agent-socket-provider", 

232 }), 

233 id="derivepassphrase-vault", 

234 ), 

235 ], 

236 ) 

237 COMPLETABLE_SUBCOMMANDS = hypothesis_machinery.explicit_examples( 

238 ["command_prefix", "incomplete", "completions"], 

239 [ 

240 pytest.param( 

241 (), 

242 "", 

243 frozenset({"export", "vault"}), 

244 id="derivepassphrase", 

245 ), 

246 pytest.param( 

247 ("export",), 

248 "", 

249 frozenset({"vault"}), 

250 id="derivepassphrase-export", 

251 ), 

252 ], 

253 ) 

254 COMPLETION_FUNCTION_INPUTS = hypothesis_machinery.explicit_examples( 

255 ["config", "comp_func", "args", "incomplete", "results"], 

256 [ 

257 pytest.param( 

258 {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, 

259 cli_helpers.shell_complete_service, 

260 ["vault"], 

261 "", 

262 [DUMMY_SERVICE], 

263 id="base_config-service", 

264 ), 

265 pytest.param( 

266 {"services": {}}, 

267 cli_helpers.shell_complete_service, 

268 ["vault"], 

269 "", 

270 [], 

271 id="empty_config-service", 

272 ), 

273 pytest.param( 

274 { 

275 "services": { 

276 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

277 "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), 

278 } 

279 }, 

280 cli_helpers.shell_complete_service, 

281 ["vault"], 

282 "", 

283 [DUMMY_SERVICE], 

284 id="incompletable_newline_config-service", 

285 ), 

286 pytest.param( 

287 { 

288 "services": { 

289 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

290 "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), 

291 } 

292 }, 

293 cli_helpers.shell_complete_service, 

294 ["vault"], 

295 "", 

296 [DUMMY_SERVICE], 

297 id="incompletable_backspace_config-service", 

298 ), 

299 pytest.param( 

300 { 

301 "services": { 

302 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

303 "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), 

304 } 

305 }, 

306 cli_helpers.shell_complete_service, 

307 ["vault"], 

308 "", 

309 sorted([DUMMY_SERVICE, "colon:in:name"]), 

310 id="brittle_colon_config-service", 

311 ), 

312 pytest.param( 

313 { 

314 "services": { 

315 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

316 "colon:in:name": DUMMY_CONFIG_SETTINGS.copy(), 

317 "newline\nin\nname": DUMMY_CONFIG_SETTINGS.copy(), 

318 "backspace\bin\bname": DUMMY_CONFIG_SETTINGS.copy(), 

319 "nul\x00in\x00name": DUMMY_CONFIG_SETTINGS.copy(), 

320 "del\x7fin\x7fname": DUMMY_CONFIG_SETTINGS.copy(), 

321 } 

322 }, 

323 cli_helpers.shell_complete_service, 

324 ["vault"], 

325 "", 

326 sorted([DUMMY_SERVICE, "colon:in:name"]), 

327 id="brittle_incompletable_multi_config-service", 

328 ), 

329 pytest.param( 

330 {"services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}, 

331 cli_helpers.shell_complete_path, 

332 ["vault", "--import"], 

333 "", 

334 [click.shell_completion.CompletionItem("", type="file")], 

335 id="base_config-path", 

336 ), 

337 pytest.param( 

338 {"services": {}}, 

339 cli_helpers.shell_complete_path, 

340 ["vault", "--import"], 

341 "", 

342 [click.shell_completion.CompletionItem("", type="file")], 

343 id="empty_config-path", 

344 ), 

345 ], 

346 ) 

347 COMPLETABLE_SERVICE_NAMES = pytest.mark.parametrize( 

348 ["config", "incomplete", "completions"], 

349 [ 

350 pytest.param( 

351 {"services": {}}, 

352 "", 

353 frozenset(), 

354 id="no_services", 

355 ), 

356 pytest.param( 

357 {"services": {}}, 

358 "partial", 

359 frozenset(), 

360 id="no_services_partial", 

361 ), 

362 pytest.param( 

363 {"services": {DUMMY_SERVICE: {"length": 10}}}, 

364 "", 

365 frozenset({DUMMY_SERVICE}), 

366 id="one_service", 

367 ), 

368 pytest.param( 

369 {"services": {DUMMY_SERVICE: {"length": 10}}}, 

370 DUMMY_SERVICE[:4], 

371 frozenset({DUMMY_SERVICE}), 

372 id="one_service_partial", 

373 ), 

374 pytest.param( 

375 {"services": {DUMMY_SERVICE: {"length": 10}}}, 

376 DUMMY_SERVICE[-4:], 

377 frozenset(), 

378 id="one_service_partial_miss", 

379 ), 

380 ], 

381 ) 

382 SERVICE_NAME_COMPLETION_INPUTS = hypothesis_machinery.explicit_examples( 

383 ["config", "key", "incomplete", "completions"], 

384 [ 

385 pytest.param( 

386 { 

387 "services": { 

388 DUMMY_SERVICE: {"length": 10}, 

389 "newline\nin\nname": {"length": 10}, 

390 }, 

391 }, 

392 "newline\nin\nname", 

393 "", 

394 frozenset({DUMMY_SERVICE}), 

395 id="newline", 

396 ), 

397 pytest.param( 

398 { 

399 "services": { 

400 DUMMY_SERVICE: {"length": 10}, 

401 "newline\nin\nname": {"length": 10}, 

402 }, 

403 }, 

404 "newline\nin\nname", 

405 "serv", 

406 frozenset({DUMMY_SERVICE}), 

407 id="newline_partial_other", 

408 ), 

409 pytest.param( 

410 { 

411 "services": { 

412 DUMMY_SERVICE: {"length": 10}, 

413 "newline\nin\nname": {"length": 10}, 

414 }, 

415 }, 

416 "newline\nin\nname", 

417 "newline", 

418 frozenset({}), 

419 id="newline_partial_specific", 

420 ), 

421 pytest.param( 

422 { 

423 "services": { 

424 DUMMY_SERVICE: {"length": 10}, 

425 "nul\x00in\x00name": {"length": 10}, 

426 }, 

427 }, 

428 "nul\x00in\x00name", 

429 "", 

430 frozenset({DUMMY_SERVICE}), 

431 id="nul", 

432 ), 

433 pytest.param( 

434 { 

435 "services": { 

436 DUMMY_SERVICE: {"length": 10}, 

437 "nul\x00in\x00name": {"length": 10}, 

438 }, 

439 }, 

440 "nul\x00in\x00name", 

441 "serv", 

442 frozenset({DUMMY_SERVICE}), 

443 id="nul_partial_other", 

444 ), 

445 pytest.param( 

446 { 

447 "services": { 

448 DUMMY_SERVICE: {"length": 10}, 

449 "nul\x00in\x00name": {"length": 10}, 

450 }, 

451 }, 

452 "nul\x00in\x00name", 

453 "nul", 

454 frozenset({}), 

455 id="nul_partial_specific", 

456 ), 

457 pytest.param( 

458 { 

459 "services": { 

460 DUMMY_SERVICE: {"length": 10}, 

461 "backspace\bin\bname": {"length": 10}, 

462 }, 

463 }, 

464 "backspace\bin\bname", 

465 "", 

466 frozenset({DUMMY_SERVICE}), 

467 id="backspace", 

468 ), 

469 pytest.param( 

470 { 

471 "services": { 

472 DUMMY_SERVICE: {"length": 10}, 

473 "backspace\bin\bname": {"length": 10}, 

474 }, 

475 }, 

476 "backspace\bin\bname", 

477 "serv", 

478 frozenset({DUMMY_SERVICE}), 

479 id="backspace_partial_other", 

480 ), 

481 pytest.param( 

482 { 

483 "services": { 

484 DUMMY_SERVICE: {"length": 10}, 

485 "backspace\bin\bname": {"length": 10}, 

486 }, 

487 }, 

488 "backspace\bin\bname", 

489 "back", 

490 frozenset({}), 

491 id="backspace_partial_specific", 

492 ), 

493 pytest.param( 

494 { 

495 "services": { 

496 DUMMY_SERVICE: {"length": 10}, 

497 "del\x7fin\x7fname": {"length": 10}, 

498 }, 

499 }, 

500 "del\x7fin\x7fname", 

501 "", 

502 frozenset({DUMMY_SERVICE}), 

503 id="del", 

504 ), 

505 pytest.param( 

506 { 

507 "services": { 

508 DUMMY_SERVICE: {"length": 10}, 

509 "del\x7fin\x7fname": {"length": 10}, 

510 }, 

511 }, 

512 "del\x7fin\x7fname", 

513 "serv", 

514 frozenset({DUMMY_SERVICE}), 

515 id="del_partial_other", 

516 ), 

517 pytest.param( 

518 { 

519 "services": { 

520 DUMMY_SERVICE: {"length": 10}, 

521 "del\x7fin\x7fname": {"length": 10}, 

522 }, 

523 }, 

524 "del\x7fin\x7fname", 

525 "del", 

526 frozenset({}), 

527 id="del_partial_specific", 

528 ), 

529 ], 

530 settings=hypothesis.settings( 

531 parent=hypothesis.settings(), 

532 suppress_health_check=[ 

533 hypothesis.HealthCheck.function_scoped_fixture 

534 ], 

535 ), 

536 ) 

537 SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize( 

538 "exc_type", [RuntimeError, KeyError, ValueError] 

539 ) 

540 INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"]) 

541 CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"]) 

542 COMPLETABLE_ITEMS = hypothesis_machinery.explicit_examples( 

543 ["partial", "is_completable"], 

544 [ 

545 ("", True), 

546 (DUMMY_SERVICE, True), 

547 ("a\bn", False), 

548 ("\b", False), 

549 ("\x00", False), 

550 ("\x20", True), 

551 ("\x7f", False), 

552 ("service with spaces", True), 

553 ("service\nwith\nnewlines", False), 

554 ], 

555 ) 

556 SHELL_FORMATTER = pytest.mark.parametrize( 

557 ["shell", "format_func"], 

558 [ 

559 pytest.param("bash", bash_format, id="bash"), 

560 pytest.param("fish", fish_format, id="fish"), 

561 pytest.param("zsh", zsh_format, id="zsh"), 

562 ], 

563 ) 

564 

565 

566class TestShellCompletion: 

567 """Tests for the shell completion machinery.""" 

568 

569 class Completions: 

570 """A deferred completion call.""" 

571 

572 def __init__( 

573 self, 

574 args: Sequence[str], 

575 incomplete: str, 

576 ) -> None: 

577 """Initialize the object. 

578 

579 Args: 

580 args: 

581 The sequence of complete command-line arguments. 

582 incomplete: 

583 The final, incomplete, partial argument. 

584 

585 """ 

586 self.args = tuple(args) 1gedcf

587 self.incomplete = incomplete 1gedcf

588 

589 def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: 

590 """Return the completion items.""" 

591 args = list(self.args) 1gedcf

592 completion = click.shell_completion.ShellComplete( 1gedcf

593 cli=cli.derivepassphrase, 

594 ctx_args={}, 

595 prog_name="derivepassphrase", 

596 complete_var="_DERIVEPASSPHRASE_COMPLETE", 

597 ) 

598 return completion.get_completions(args, self.incomplete) 1gedcf

599 

600 def get_words(self) -> Sequence[str]: 

601 """Return the completion items' values, as a sequence.""" 

602 return tuple(c.value for c in self()) 1gdcf

603 

604 

605class TestCompletableItems: 

606 """Tests for completablility of items.""" 

607 

608 @Parametrize.COMPLETABLE_ITEMS 

609 def test_is_completable_item( 1aj

610 self, 

611 partial: str, 

612 is_completable: bool, 

613 ) -> None: 

614 """Our `_is_completable_item` predicate for service names works.""" 

615 assert cli_helpers.is_completable_item(partial) == is_completable 1j

616 

617 

618class TestCompletableItemClasses(TestShellCompletion): 

619 """Tests for the different classes of completable items.""" 

620 

621 @Parametrize.COMPLETABLE_OPTIONS 

622 def test_options( 

623 self, 

624 command_prefix: Sequence[str], 

625 incomplete: str, 

626 completions: AbstractSet[str], 

627 ) -> None: 

628 """Our completion machinery works for all commands' options.""" 

629 comp = self.Completions(command_prefix, incomplete) 1g

630 assert frozenset(comp.get_words()) == completions 1g

631 

632 @Parametrize.COMPLETABLE_SUBCOMMANDS 

633 def test_subcommands( 1af

634 self, 

635 command_prefix: Sequence[str], 

636 incomplete: str, 

637 completions: AbstractSet[str], 

638 ) -> None: 

639 """Our completion machinery works for all commands' subcommands.""" 

640 comp = self.Completions(command_prefix, incomplete) 1f

641 assert frozenset(comp.get_words()) == completions 1f

642 

643 @Parametrize.COMPLETABLE_PATH_ARGUMENT 

644 @Parametrize.INCOMPLETE 

645 def test_paths( 

646 self, 

647 command_prefix: Sequence[str], 

648 incomplete: str, 

649 ) -> None: 

650 """Our completion machinery works for all commands' paths.""" 

651 file = click.shell_completion.CompletionItem("", type="file") 1e

652 completions = frozenset({(file.type, file.value, file.help)}) 1e

653 comp = self.Completions(command_prefix, incomplete) 1e

654 assert ( 1e

655 frozenset((x.type, x.value, x.help) for x in comp()) == completions 

656 ) 

657 

658 @Parametrize.COMPLETABLE_SERVICE_NAMES 

659 def test_service_names( 

660 self, 

661 config: _types.VaultConfig, 

662 incomplete: str, 

663 completions: AbstractSet[str], 

664 ) -> None: 

665 """Our completion machinery works for vault service names.""" 

666 runner = machinery.CliRunner(mix_stderr=False) 1d

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

668 # with-statements. 

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

670 with contextlib.ExitStack() as stack: 1d

671 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1d

672 stack.enter_context( 1d

673 pytest_machinery.isolated_vault_config( 

674 monkeypatch=monkeypatch, 

675 runner=runner, 

676 vault_config=config, 

677 ) 

678 ) 

679 comp = self.Completions(["vault"], incomplete) 1d

680 assert frozenset(comp.get_words()) == completions 1d

681 

682 

683class TestCompletionFormatting: 

684 """Tests for the formatting of completable items.""" 

685 

686 @Parametrize.SHELL_FORMATTER 

687 @Parametrize.COMPLETION_FUNCTION_INPUTS 1ab

688 def test_shell_completion_formatting( 

689 self, 

690 shell: str, 

691 format_func: Callable[[click.shell_completion.CompletionItem], str], 

692 config: _types.VaultConfig, 

693 comp_func: Callable[ 

694 [click.Context, click.Parameter, str], 

695 list[str | click.shell_completion.CompletionItem], 

696 ], 

697 args: list[str], 

698 incomplete: str, 

699 results: list[str | click.shell_completion.CompletionItem], 

700 ) -> None: 

701 """Custom completion functions work for all shells.""" 

702 runner = machinery.CliRunner(mix_stderr=False) 1b

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

704 # with-statements. 

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

706 with contextlib.ExitStack() as stack: 1b

707 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b

708 stack.enter_context( 1b

709 pytest_machinery.isolated_vault_config( 

710 monkeypatch=monkeypatch, 

711 runner=runner, 

712 vault_config=config, 

713 ) 

714 ) 

715 expected_items = [assertable_item(item) for item in results] 1b

716 expected_string = "\n".join( 1b

717 format_func(completion_item(item)) for item in results 

718 ) 

719 manual_raw_items = comp_func( 1b

720 click.Context(cli.derivepassphrase), 

721 click.Argument(["sample_parameter"]), 

722 incomplete, 

723 ) 

724 manual_items = [assertable_item(item) for item in manual_raw_items] 1b

725 manual_string = "\n".join( 1b

726 format_func(completion_item(item)) for item in manual_raw_items 

727 ) 

728 assert manual_items == expected_items 1b

729 assert manual_string == expected_string 1b

730 comp_class = click.shell_completion.get_completion_class(shell) 1b

731 assert comp_class is not None 1b

732 comp = comp_class( 1b

733 cli.derivepassphrase, 

734 {}, 

735 "derivepassphrase", 

736 "_DERIVEPASSPHRASE_COMPLETE", 

737 ) 

738 monkeypatch.setattr( 1b

739 comp, 

740 "get_completion_args", 

741 lambda *_a, **_kw: (args, incomplete), 

742 ) 

743 actual_raw_items = comp.get_completions( 1b

744 *comp.get_completion_args() 

745 ) 

746 actual_items = [assertable_item(item) for item in actual_raw_items] 1b

747 actual_string = comp.complete() 1b

748 assert actual_items == expected_items 1b

749 assert actual_string == expected_string 1b

750 

751 

752class TestCLICompletabilityHandling(TestShellCompletion): 

753 """Tests for how the command-line interface handles completability.""" 

754 

755 @Parametrize.CONFIG_SETTING_MODE 

756 @Parametrize.SERVICE_NAME_COMPLETION_INPUTS 1ac

757 def test_cli_warns_and_completion_skips_incompletable_service_names( 

758 self, 

759 caplog: pytest.LogCaptureFixture, 

760 mode: Literal["config", "import"], 

761 config: _types.VaultConfig, 

762 key: str, 

763 incomplete: str, 

764 completions: AbstractSet[str], 

765 ) -> None: 

766 """Completion skips incompletable items.""" 

767 caplog.clear() 1c

768 vault_config = config if mode == "config" else {"services": {}} 1c

769 runner = machinery.CliRunner(mix_stderr=False) 1c

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

771 # with-statements. 

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

773 with contextlib.ExitStack() as stack: 1c

774 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1c

775 stack.enter_context( 1c

776 pytest_machinery.isolated_vault_config( 

777 monkeypatch=monkeypatch, 

778 runner=runner, 

779 vault_config=vault_config, 

780 ) 

781 ) 

782 if mode == "config": 1c

783 result = runner.invoke( 1c

784 cli.derivepassphrase_vault, 

785 ["--config", "--length=10", "--", key], 

786 catch_exceptions=False, 

787 ) 

788 else: 

789 result = runner.invoke( 1c

790 cli.derivepassphrase_vault, 

791 ["--import", "-"], 

792 catch_exceptions=False, 

793 input=json.dumps(config), 

794 ) 

795 assert result.clean_exit(), "expected clean exit" 1c

796 assert machinery.warning_emitted( 1c

797 "contains an ASCII control character", caplog.record_tuples 

798 ), "expected known warning message in stderr" 

799 assert machinery.warning_emitted( 1c

800 "not be available for completion", caplog.record_tuples 

801 ), "expected known warning message in stderr" 

802 assert cli_helpers.load_config() == config 1c

803 comp = self.Completions(["vault"], incomplete) 1c

804 assert frozenset(comp.get_words()) == completions 1c

805 

806 def test_handling_nonexistant_service_names( 

807 self, 

808 ) -> None: 

809 """Service name completion quietly fails on missing configuration.""" 

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

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

812 # with-statements. 

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

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

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

816 stack.enter_context( 1i

817 pytest_machinery.isolated_vault_config( 

818 monkeypatch=monkeypatch, 

819 runner=runner, 

820 vault_config={ 

821 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

822 }, 

823 ) 

824 ) 

825 cli_helpers.config_filename(subsystem="vault").unlink( 1i

826 missing_ok=True 

827 ) 

828 assert not cli_helpers.shell_complete_service( 1i

829 click.Context(cli.derivepassphrase), 

830 click.Argument(["some_parameter"]), 

831 "", 

832 ) 

833 

834 # TODO(the-13th-letter): Reevaluate whether to keep this as a 

835 # non-hypothesis test. 

836 @Parametrize.SERVICE_NAME_EXCEPTIONS 

837 def test_handling_unexpected_exceptions( 

838 self, 

839 exc_type: type[Exception], 

840 ) -> None: 

841 """Service name completion quietly fails on configuration errors.""" 

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

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

844 # with-statements. 

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

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

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

848 stack.enter_context( 1h

849 pytest_machinery.isolated_vault_config( 

850 monkeypatch=monkeypatch, 

851 runner=runner, 

852 vault_config={ 

853 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS} 

854 }, 

855 ) 

856 ) 

857 

858 def raiser(*_a: Any, **_kw: Any) -> NoReturn: 1h

859 raise exc_type("just being difficult") # noqa: EM101,TRY003 1h

860 

861 monkeypatch.setattr(cli_helpers, "load_config", raiser) 1h

862 assert not cli_helpers.shell_complete_service( 1h

863 click.Context(cli.derivepassphrase), 

864 click.Argument(["some_parameter"]), 

865 "", 

866 )