# SPDX-FileCopyrightText: 2026 Marco Ricci # # SPDX-License-Identifier: Zlib """Tests for the `derivepassphrase` command-line interface: all subcommands. This includes tests for functionality or options common to all subcommands. """ from __future__ import annotations import collections import contextlib import enum import functools import operator import re import string import types from typing import TYPE_CHECKING, TypeVar, cast import exceptiongroup import hypothesis import pytest from hypothesis import strategies from typing_extensions import NamedTuple, overload from derivepassphrase import _types, cli, ssh_agent from derivepassphrase._internals import cli_machinery, cli_messages from tests import machinery from tests.machinery import pytest as pytest_machinery if TYPE_CHECKING: from collections.abc import Generator, Iterable, Sequence class VersionOutputData(NamedTuple): derivation_schemes: dict[str, bool] foreign_configuration_formats: dict[str, bool] extras: frozenset[str] subcommands: frozenset[str] features: dict[str, bool] ssh_agent_socket_providers: dict[str, bool] def _label_text(e: cli_messages.Label, /) -> str: return e.value.singular.rstrip(":") class KnownLineType(str, enum.Enum): SUPPORTED_FOREIGN_CONFS = _label_text( cli_messages.Label.SUPPORTED_FOREIGN_CONFIGURATION_FORMATS ) UNAVAILABLE_FOREIGN_CONFS = _label_text( cli_messages.Label.UNAVAILABLE_FOREIGN_CONFIGURATION_FORMATS ) SUPPORTED_SCHEMES = _label_text( cli_messages.Label.SUPPORTED_DERIVATION_SCHEMES ) UNAVAILABLE_SCHEMES = _label_text( cli_messages.Label.UNAVAILABLE_DERIVATION_SCHEMES ) SUPPORTED_SUBCOMMANDS = _label_text( cli_messages.Label.SUPPORTED_SUBCOMMANDS ) SUPPORTED_FEATURES = _label_text(cli_messages.Label.SUPPORTED_FEATURES) UNAVAILABLE_FEATURES = _label_text(cli_messages.Label.UNAVAILABLE_FEATURES) SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS = _label_text( cli_messages.Label.SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS ) UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS = _label_text( cli_messages.Label.UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS ) ENABLED_EXTRAS = _label_text(cli_messages.Label.ENABLED_PEP508_EXTRAS) class ParseStateAndExpectedResult(NamedTuple): starting_state: cli_machinery.ParseState expected_result: cli_machinery.ParseResult class Parametrize(types.SimpleNamespace): """Common test parametrizations.""" EAGER_ARGUMENTS = pytest.mark.parametrize( "arguments", [["--help"], ["--version"]], ids=["help", "version"], ) COMMAND_NON_EAGER_ARGUMENTS = pytest.mark.parametrize( ["command", "non_eager_arguments"], [ pytest.param( [], [], id="top-nothing", ), pytest.param( [], ["export"], id="top-export", ), pytest.param( ["export"], [], id="export-nothing", ), pytest.param( ["export"], ["vault"], id="export-vault", ), pytest.param( ["export", "vault"], [], id="export-vault-nothing", ), pytest.param( ["export", "vault"], ["--format", "this-format-doesnt-exist"], id="export-vault-args", ), pytest.param( ["vault"], [], id="vault-nothing", ), pytest.param( ["vault"], ["--export", "./"], id="vault-args", ), ], ) HELP_OUTPUT_COMMAND_LINE = pytest.mark.parametrize( ["command_line", "expected_lines"], [ pytest.param( [], ["currently implemented subcommands"], id="derivepassphrase", ), pytest.param( ["export"], ["only available subcommand"], id="derivepassphrase-export", ), pytest.param( ["export", "vault"], ["Export a vault-native configuration"], id="derivepassphrase-export-vault", ), pytest.param( ["vault"], [ "Passphrase generation:", "Use $VISUAL or $EDITOR to configure", ], id="derivepassphrase-vault", ), ], ) COLORFUL_COMMAND_INPUT = pytest.mark.parametrize( ["command_line", "input"], [ ( ["vault", "--import", "-"], '{"services": {"": {"length": 20}}}', ), ], ids=["cmd"], ) ISATTY = pytest.mark.parametrize( "isatty", [False, True], ids=["notty", "tty"], ) MASK_PROG_NAME = pytest.mark.parametrize( "mask_prog_name", [False, True], ids=["clear_prog_name", "masked_prog_name"], ) MASK_VERSION = pytest.mark.parametrize( "mask_version", [False, True], ids=["clear_version", "masked_version"] ) VERSION_OUTPUT_DATA = pytest.mark.parametrize( ["version_output", "prog_name", "version", "expected_parse"], [ pytest.param( """\ derivepassphrase 0.4.0 Using cryptography 44.0.0 Supported foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. PEP 508 extras: export. """, "derivepassphrase", "0.4.0", VersionOutputData( derivation_schemes={}, foreign_configuration_formats={ "vault storeroom": True, "vault v0.2": True, "vault v0.3": True, }, subcommands=frozenset(), features={}, extras=frozenset({"export"}), ssh_agent_socket_providers={}, ), id="derivepassphrase-0.4.0-export", ), pytest.param( """\ derivepassphrase 0.5 Supported derivation schemes: vault. Known foreign configuration formats: vault storeroom, vault v0.2, vault v0.3. Supported subcommands: export, vault. No PEP 508 extras are active. """, "derivepassphrase", "0.5", VersionOutputData( derivation_schemes={"vault": True}, foreign_configuration_formats={ "vault storeroom": False, "vault v0.2": False, "vault v0.3": False, }, subcommands=frozenset({"export", "vault"}), features={}, extras=frozenset({}), ssh_agent_socket_providers={}, ), id="derivepassphrase-0.5-plain", ), pytest.param( """\ inventpassphrase -1.3 Using not-a-library 7.12 Copyright 2025 Nobody. All rights reserved. Supported derivation schemes: nonsense. Known derivation schemes: divination, /dev/random, geiger counter, crossword solver. Supported foreign configuration formats: derivepassphrase, nonsense. Known foreign configuration formats: divination v3.141592, /dev/random. Supported subcommands: delete-all-files, dump-core. Supported features: delete-while-open. Known features: backups-are-nice-to-have. Supported SSH agent socket providers: agents-of-shield. Known SSH agent socket providers: agent-smith. PEP 508 extras: annoying-popups, delete-all-files, dump-core-depending-on-the-phase-of-the-moon. """, "inventpassphrase", "-1.3", VersionOutputData( derivation_schemes={ "nonsense": True, "divination": False, "/dev/random": False, "geiger counter": False, "crossword solver": False, }, foreign_configuration_formats={ "derivepassphrase": True, "nonsense": True, "divination v3.141592": False, "/dev/random": False, }, subcommands=frozenset({"delete-all-files", "dump-core"}), features={ "delete-while-open": True, "backups-are-nice-to-have": False, }, extras=frozenset({ "annoying-popups", "delete-all-files", "dump-core-depending-on-the-phase-of-the-moon", }), ssh_agent_socket_providers={ "agents-of-shield": True, "agent-smith": False, }, ), id="inventpassphrase", ), pytest.param( """\ derivepassphrase 1.0 Using wishful-thinking 2.0. Supported derivation schemes: spectre ({aliases!s} master-password, mpw), vault. Supported subcommands: export, spectre ({aliases!s} master-password, mpw), vault. """.format( aliases=cli_messages.TranslatedString( cli_messages.Label.FEATURE_ITEM_ALIASES ) ), "derivepassphrase", "1.0", VersionOutputData( derivation_schemes={ "master-password": True, "mpw": True, "spectre": True, "vault": True, }, foreign_configuration_formats={}, subcommands=frozenset({ "export", "master-password", "mpw", "spectre", "vault", }), features={}, extras=frozenset(), ssh_agent_socket_providers={}, ), id="aliases", ), ], ) """Sample data for [`parse_version_output`][].""" def roman_numerals() -> Generator[str, None, None]: """Generate lowercase roman numerals, up to 3999.""" ones = ["", "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix"] tens = ["", "x", "xx", "xxx", "xl", "l", "lx", "lxx", "lxxx", "xc"] huns = ["", "c", "cc", "ccc", "cd", "d", "dc", "dcc", "dccc", "cm"] thou = ["", "m", "mm", "mmm"] # Start at "i". for i in range(1, 4000): yield "".join([ thou[(i // 1000) % len(thou)], huns[(i // 100) % len(huns)], tens[(i // 10) % len(tens)], ones[(i // 1) % len(ones)], ]) def make_dummy_options( name_counter: Iterable[str], short_name_counter: Iterable[str], /, ) -> Generator[cli_machinery.CLIOption, None, None]: """Generate dummy CLIOption objects. Args: name_counter: An iterable for unique names, to be used as long option names. short_name_counter: An iterable of short option "letters", to be used for short option names. Yields: CLIOption objects with hitherto unseen option names. Some options will be eager, some will take an argument, some will do both. """ has_argument_list: list[bool | tuple[str, ...]] = [ False, True, ("arg1", "arg2", "arg3", "arg4", "arg5", "arg6"), ] eager_list = [False, True] for i, (name, letter) in enumerate(zip(name_counter, short_name_counter)): i_has_argument = i // len(eager_list) i_eager = i names = ( f"--{name}", f"-{letter}", f"--alias-{name}", f"--alternate-{name}", ) help = f"Help for option --{name}." # noqa: A001 has_argument = has_argument_list[ i_has_argument % len(has_argument_list) ] eager = eager_list[i_eager % len(eager_list)] yield cli_machinery.CLIOption( names=names, help=help, has_argument=has_argument, eager=eager ) def make_dummy_option_groups( name_counter: Iterable[str], short_name_counter: Iterable[str], /, ) -> Generator[cli_machinery.CLIOptionGroup, None, None]: """Generate dummy CLIOptionGroup objects. The option objects are generated via [`make_dummy_options`][]. They are grouped such that each option has a unique combination of eagerness and argument requirements. Args: name_counter: An iterable for unique names, to be used as long option names. Passed to [`make_dummy_options`][]. short_name_counter: An iterable of short option "letters", to be used for short option names. Passed to [`make_dummy_options`][]. Yields: CLIOptionGroup objects. The embedded CLIOption objects will have hitherto unseen option names. Some options will be eager, some will take an argument, some will do both. """ option_states_seen: set[tuple[bool, bool | tuple[str, ...]]] = set() options_collected: list[cli_machinery.CLIOption] = [] i = 1 for option in make_dummy_options(name_counter, short_name_counter): option_state = ( option.eager, option.has_argument if isinstance(option.has_argument, bool) else tuple(option.has_argument), ) if option_state in option_states_seen: yield cli_machinery.CLIOptionGroup( options=tuple(options_collected), title=f"Group {i}", epilog=f"Group {i} epilog.", ) option_states_seen.clear() options_collected.clear() i += 1 options_collected.append(option) option_states_seen.add(option_state) if options_collected: yield cli_machinery.CLIOptionGroup( options=tuple(options_collected), title=f"Group {i}", epilog=f"Group {i} epilog.", ) def make_dummy_command_names() -> Generator[str, None, None]: """Generate dummy subcommand names. The names are drawn from actual program subcommands. Yields: Unique names from actual program subcommands. """ subpools = [ ( "add rm status checkout branch switch merge diff log push pull " "revert reset rebase bisect bundle cherry-pick gc clone init " "stash worktree tag remote blame" ), # git "vault", # derivepassphrase "all check install test clean", # make ( "bye cd chgrp chmod chown copy cp df exit get help lcd lls " "lmkdir ln lpwd ls lumask mkdir progress put pwd quit " "reget reput rename rm rmdir symlink version" ), # OpenSSH sftp ( "alias bg cd chdir command echo eval exec exit export fc " "fg getopts hash jobs kill pwd read readonly printf set shift " "test times trap type ulimit umask unalias unset wait" ), # dash/POSIX sh(1) ( "bind builtin caller compgen complete compopt declare disown " "enable history let local logout mapfile popd pushd return " "shopt source suspend" ), # bash ] seen: set[str] = set() for subpool in subpools: for arg in subpool.split(): if arg not in seen: seen.add(arg) yield arg OPTION_GROUP_POOL = tuple( make_dummy_option_groups(roman_numerals(), sorted(string.ascii_letters)) ) """A pool of disjoint option group objects from which individual option groups can be drawn without consuming too much entropy.""" OPTION_POOL = tuple( option for group in OPTION_GROUP_POOL for option in group.options ) """A pool of disjoint option objects from which individual options can be drawn without consuming too much entropy.""" COMMAND_NAMES_POOL = tuple(make_dummy_command_names()) """A pool of unique subcommand names from which individual names can be drawn without consuming too much entropy.""" def _is_short_option(name: str) -> bool: return not name.startswith("--") def _name_complexity(name: str) -> tuple[int, str]: return len(name), name def _option_name_complexity(opt: cli_machinery.CLIOption) -> tuple[int, int]: has_short_option = any(_is_short_option(name) for name in opt.names) return ( 0 if has_short_option else 1, 0 if opt.has_argument else 1, ) def _option_group_complexity(group: cli_machinery.CLIOptionGroup) -> int: option_complexities = [ _option_name_complexity(opt) for opt in group.options ] penalty_no_short_option = sum(cplx[0] for cplx in option_complexities) penalty_argument = sum(cplx[1] for cplx in option_complexities) size_penalty = ( len(option_complexities[0]) * len(option_complexities) if option_complexities else 0 ) return size_penalty + penalty_no_short_option + penalty_argument def _options_are_unique( groups: Sequence[cli_machinery.CLIOptionGroup], /, ) -> bool: all_option_names = [ name for group in groups for opt in group.options for name in opt.names ] option_names = set(all_option_names) return len(all_option_names) == len(option_names) T = TypeVar("T") @overload def _flatten(nested_list: Sequence[list[T]], /) -> list[T]: ... @overload def _flatten(nested_list: Sequence[tuple[T, ...]], /) -> tuple[T, ...]: ... @overload def _flatten( nested_list: Sequence[collections.deque[T]], / ) -> collections.deque[T]: ... def _flatten( nested_list: Sequence[list | tuple | collections.deque], /, ) -> list | tuple | collections.deque: if not nested_list: raise ValueError( # noqa: TRY003 "Cannot flatten empty sequence without constructor factory" # noqa: EM101 ) first = nested_list[0] if isinstance(first, list): return functools.reduce(operator.add, nested_list, []) if isinstance(first, tuple): return functools.reduce(operator.add, nested_list, ()) if isinstance(first, collections.deque): return functools.reduce(operator.add, nested_list, collections.deque()) raise ValueError( # noqa: TRY003 "Cannot flatten things that aren't tuples, lists or deques" # noqa: EM101 ) class Strategies(types.SimpleNamespace): """Common hypothesis strategies.""" WORDS = strategies.text(string.ascii_lowercase, min_size=1) METAVARS = strategies.text(string.ascii_uppercase, min_size=1, max_size=7) COMMAND_NAMES = strategies.sampled_from( sorted(COMMAND_NAMES_POOL, key=len) ) NUM_NAMES = 4 NUM_OPTIONS_PER_GROUP = 2 @staticmethod def option_pool() -> tuple[cli_machinery.CLIOption, ...]: return OPTION_POOL @staticmethod def options() -> strategies.SearchStrategy[cli_machinery.CLIOption]: pool = Strategies.option_pool() return strategies.sampled_from(pool) @staticmethod def option_group_pool( *, allow_eager: bool = False, ) -> tuple[cli_machinery.CLIOptionGroup, ...]: pool1 = [ cli_machinery.CLIOptionGroup( options=tuple( opt for opt in group.options if not opt.eager or allow_eager ), title=group.title, epilog=group.epilog, ) for group in OPTION_GROUP_POOL ] pool2 = [group for group in pool1 if group.options] return tuple(pool2) @staticmethod def option_groups( *, allow_eager: bool = False, ) -> strategies.SearchStrategy[cli_machinery.CLIOptionGroup]: pool = Strategies.option_group_pool(allow_eager=allow_eager) return strategies.sampled_from(pool) @staticmethod @strategies.composite def terminal_subcommands( draw: strategies.DrawFn, /, *, allow_eager: bool = False, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> cli_machinery.CLITerminalSubcommand: names = draw( strategies.lists( Strategies.COMMAND_NAMES, min_size=1, max_size=Strategies.NUM_NAMES, unique=True, ), "names", ) option_groups = draw( strategies.lists( Strategies.option_groups(allow_eager=allow_eager), min_size=1, max_size=max_option_groups, ).filter(_options_are_unique), "option_groups", ) positionals = draw( strategies.lists( Strategies.METAVARS.map(cli_machinery.CLIArgument), max_size=max_positionals, unique_by=lambda arg: arg.name, ), "positionals", ) return cli_machinery.CLITerminalSubcommand( names=tuple(names), contents=(tuple(option_groups), tuple(positionals)), prolog=("Subcommand prolog goes here.",), epilog=("Subcommand epilog goes here.",), ) @staticmethod def clustered_options_with_final_argument( *options: cli_machinery.CLIOption, ) -> strategies.SearchStrategy[list[str]]: if not options: # pragma: no cover [failsafe] msg = "No options given!" raise ValueError(msg) for opt in options: # pragma: no cover [failsafe] if not any(_is_short_option(name) for name in opt.names): msg = f"Option object has no short options: {opt!r}" raise ValueError(msg) if not options[-1].has_argument: # pragma: no cover [failsafe] msg = f"Option does not accept an argument: {opt!r}" raise ValueError(msg) def cluster_options(args: tuple[str, ...]) -> list[str]: first = args[0] # in full middle = "".join(opt[-1:] for opt in args[1:-1]) # no "-" last = args[-1] # maybe an argument return [f"{first}{middle}{last}"] eligible_options = [ tuple(name for name in opt.names if _is_short_option(name)) for opt in options ] strategies_ = [ strategies.just(names[0]) if len(names) == 1 else strategies.sampled_from(names) for names in eligible_options ] strategies_.append(Strategies.WORDS) return strategies.tuples(*strategies_).map(cluster_options) @staticmethod def option_and_argument( opt: cli_machinery.CLIOption, /, free_arguments_strategy: strategies.SearchStrategy[str] | None = None, *, normalized: bool = True, ) -> strategies.SearchStrategy[list[str]]: # shrink to less complex option names option_names = ( [opt.names[0]] if normalized else sorted(opt.names, key=_name_complexity) ) option_strategy = strategies.sampled_from(option_names) if not isinstance(opt.has_argument, bool): # shrink to less complex arguments arguments = sorted(opt.has_argument, key=_name_complexity) return strategies.tuples( option_strategy, strategies.sampled_from(arguments) ).map(list) if opt.has_argument: return strategies.tuples( option_strategy, free_arguments_strategy if free_arguments_strategy is not None else Strategies.WORDS, ).map(list) return strategies.tuples(option_strategy).map(list) @staticmethod @strategies.composite def maybe_connect_options_and_arguments( draw: strategies.DrawFn, pairs: list[list[str]], /, ) -> list[list[str]]: return [ ["=".join(pair)] if len(pair) > 1 and pair[0].startswith("--") and draw(strategies.booleans(), f"connect pair {i}") else pair for i, pair in enumerate(pairs) ] @staticmethod @strategies.composite def choose_clusters( draw: strategies.DrawFn, snippet: list[str], /, ) -> list[str]: result: list[str] = [] for i, token in enumerate(snippet): if i == 0: result.append(token) continue previous_token = snippet[i - 1] if ( previous_token.startswith("--") or not previous_token.startswith("-") or token.startswith("--") ): result.append(token) else: # token is an argument, or a short option extend_cluster = draw( strategies.booleans(), f"cluster[{i - 1}, {i}]" ) if extend_cluster: result[-1] += token[1] if token.startswith("-") else token else: result.append(token) return result @staticmethod def arrange_options( *options: cli_machinery.CLIOption, free_arguments_strategy: strategies.SearchStrategy[str] | None = None, normalized: bool = True, ) -> strategies.SearchStrategy[list[list[str]]]: pairs_strategies = [ Strategies.option_and_argument( opt, free_arguments_strategy, normalized=normalized ) for opt in options ] return strategies.tuples(*pairs_strategies).map(list) @staticmethod def choose_option( options: Sequence[cli_machinery.CLIOption], /, ) -> strategies.SearchStrategy[cli_machinery.CLIOption]: # shrink to less complex options return strategies.sampled_from( sorted(options, key=_option_name_complexity) ) @staticmethod def choose_options( *options: cli_machinery.CLIOption, free_arguments_strategy: strategies.SearchStrategy[str] | None = None, normalized: bool = True, ) -> strategies.SearchStrategy[list[list[str]]]: def _option_and_argument( opt: cli_machinery.CLIOption, /, ) -> strategies.SearchStrategy[list[str]]: return Strategies.option_and_argument( opt, free_arguments_strategy, normalized=normalized ) return strategies.lists( Strategies.choose_option(options).flatmap(_option_and_argument), max_size=2 * len(options), ) @staticmethod def any_option_and_argument_pairs( *options: cli_machinery.CLIOption, free_arguments_strategy: strategies.SearchStrategy[str] | None = None, in_order: bool = True, normalized: bool = True, ) -> strategies.SearchStrategy[list[list[str]]]: if not options: # pragma: no cover [failsafe] msg = "No options given!" raise ValueError(msg) return ( Strategies.arrange_options( *options, free_arguments_strategy=free_arguments_strategy, normalized=normalized, ) if in_order else Strategies.choose_options( *options, free_arguments_strategy=free_arguments_strategy, normalized=normalized, ) ) @staticmethod @strategies.composite def any_options( draw: strategies.DrawFn, option_and_argument_pairs_strategy: strategies.SearchStrategy[ list[list[str]] ], /, *, normalized: bool = True, ) -> list[str]: def choose_clusters( tokens: list[str], ) -> strategies.SearchStrategy[list[str]]: return ( strategies.just(tokens) if normalized else Strategies.choose_clusters(tokens) ) raw_option_argument_pairs = draw( option_and_argument_pairs_strategy, "raw option/argument pairs" ) connected_option_argument_pairs = ( [ ["=".join(pair)] if len(pair) > 1 and pair[0].startswith("--") else pair for pair in raw_option_argument_pairs ] if normalized else draw( Strategies.maybe_connect_options_and_arguments( raw_option_argument_pairs ), "connected option/argument pairs", ) ) unclustered_arguments = _flatten(connected_option_argument_pairs) return draw( choose_clusters(unclustered_arguments), "clustered command-line" ) @staticmethod @strategies.composite def composite_subcommands( draw: strategies.DrawFn, subcommands: strategies.SearchStrategy[cli_machinery.CLISubcommand], /, wrapped_subcommand: cli_machinery.CLISubcommand | None = None, *, allow_default_subcommand: bool = True, allow_eager: bool = False, max_option_groups: int = NUM_NAMES, max_subcommands: int = NUM_NAMES, ) -> cli_machinery.CLICompositeSubcommand: k = max_option_groups n = max_subcommands names = draw( strategies.lists( Strategies.COMMAND_NAMES, min_size=1, max_size=Strategies.NUM_NAMES, unique=True, ), "names", ) option_groups = draw( strategies.lists( Strategies.option_groups(allow_eager=allow_eager), min_size=1, max_size=k, ).filter(_options_are_unique), "option_groups", ) def subcommand_names_are_unique( subcommands: list[cli_machinery.CLISubcommand], ) -> bool: names = [name for cmd in subcommands for name in cmd.names] return len(names) == len(set(names)) subcommand_list_strategy = ( strategies.lists(subcommands, max_size=n - 1).map( lambda others: [wrapped_subcommand, *others] ) if wrapped_subcommand is not None else strategies.lists(subcommands, min_size=1, max_size=n) ) subcommand_list = draw( subcommand_list_strategy.filter(subcommand_names_are_unique), "subcommands", ) default_subcommand = ( draw( strategies.one_of( strategies.none(), strategies.just(subcommand_list[0].names[0]), ), "default_subcommand", ) if allow_default_subcommand else None ) return cli_machinery.CLICompositeSubcommand( names=tuple(names), contents=( tuple(option_groups), tuple(subcommand_list), default_subcommand, ), prolog=("Subcommand prolog goes here.",), epilog=("Subcommand epilog goes here.",), ) @staticmethod def subcommands( *, allow_eager: bool = False, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> strategies.SearchStrategy[cli_machinery.CLISubcommand]: terminals = Strategies.terminal_subcommands( allow_eager=allow_eager, max_option_groups=max_option_groups, max_positionals=max_positionals, ) return strategies.recursive( terminals, lambda strat: Strategies.composite_subcommands( strat, allow_default_subcommand=True, allow_eager=allow_eager, max_option_groups=max_option_groups, max_subcommands=max_positionals, ), ) @staticmethod @strategies.composite def subcommand_and_symbolic_command_line( draw: strategies.DrawFn, /, *, allow_eager: bool = False, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> tuple[ cli_machinery.CLISubcommand, list[ tuple[ tuple[cli_machinery.CLIOption, ...], tuple[cli_machinery.CLIArgument, ...] | cli_machinery.CLISubcommand, ] ], ]: @strategies.composite def options_strategy( draw: strategies.DrawFn, options: Sequence[cli_machinery.CLIOption], /, *, force_clusterable: bool = False, ) -> tuple[cli_machinery.CLIOption, ...]: n = draw( strategies.integers(0, Strategies.NUM_NAMES), "num_options" ) if not n: return () with_args = [opt for opt in options if opt.has_argument] without_args = [opt for opt in options if not opt.has_argument] options_without_arguments = strategies.sampled_from(without_args) options_with_arguments = strategies.sampled_from(with_args) options_maybe_with_arguments = strategies.sampled_from(options) if not force_clusterable: return draw( strategies.lists( options_maybe_with_arguments, min_size=n, max_size=n ).map(tuple), "options", ) last_opt = draw(options_with_arguments, "last_option") preceding_opts = draw( strategies.lists( options_without_arguments, min_size=n - 1, max_size=n - 1 ), "preceding_options", ) return (*preceding_opts, last_opt) terminal_subcommands = Strategies.terminal_subcommands( allow_eager=allow_eager ) subcommand_stack = collections.deque([ cast( "cli_machinery.CLISubcommand", draw(terminal_subcommands, "terminal_subcommand"), ) ]) subcommand_depth = draw(strategies.integers(1, 4), "subcommand_depth") for counter in range(1, subcommand_depth): subcommand_stack.appendleft( draw( Strategies.composite_subcommands( terminal_subcommands, wrapped_subcommand=subcommand_stack[0], allow_default_subcommand=True, allow_eager=allow_eager, max_option_groups=max_option_groups, max_subcommands=max_positionals, ), f"parent_subcommand #{counter}", ) ) command_line_symbolic_sections: list[ tuple[ tuple[cli_machinery.CLIOption, ...], tuple[cli_machinery.CLIArgument, ...] | cli_machinery.CLISubcommand, ] ] = [] for i, subcommand in enumerate(subcommand_stack): counter = i + 1 eligible_options = [ opt for group in subcommand.contents[0] for opt in group.options ] section_options = draw( options_strategy(eligible_options), f"options #{counter}", ) section: tuple[ tuple[cli_machinery.CLIOption, ...], cli_machinery.CLISubcommand | tuple[cli_machinery.CLIArgument, ...], ] if isinstance(subcommand, cli_machinery.CLITerminalSubcommand): arguments = subcommand.contents[1] section = (tuple(section_options), tuple(arguments)) else: next_subcommand = subcommand_stack[i + 1] section = (tuple(section_options), next_subcommand) command_line_symbolic_sections.append(section) return (subcommand_stack[0], command_line_symbolic_sections) @staticmethod @strategies.composite def parse_states( draw: strategies.DrawFn, /, subcommand_and_symbolic_command_line: tuple[ cli_machinery.CLISubcommand, list[ tuple[ tuple[cli_machinery.CLIOption, ...], tuple[cli_machinery.CLIArgument, ...] | cli_machinery.CLISubcommand, ] ], ] | None = None, *, allow_eager: bool = False, normalized: bool = True, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> cli_machinery.ParseState: subcommand, command_line_symbolic_sections = ( subcommand_and_symbolic_command_line if subcommand_and_symbolic_command_line is not None else draw( Strategies.subcommand_and_symbolic_command_line( allow_eager=allow_eager, max_option_groups=max_option_groups, max_positionals=max_positionals, ), "subcommand/symbolic command-line", ) ) command_line: list[str] = [] for i, section in enumerate(command_line_symbolic_sections): options, subcommand_or_arguments = section command_line.extend( draw( Strategies.any_options( Strategies.any_option_and_argument_pairs( *options, in_order=True, normalized=normalized, ), normalized=normalized, ), f"section {i} options", ) if options else [] ) command_line.extend( ["--"] if normalized or draw(strategies.booleans(), "end_of_options_marker") else [] ) if isinstance( subcommand_or_arguments, ( cli_machinery.CLITerminalSubcommand, cli_machinery.CLICompositeSubcommand, ), ): command_line.append(subcommand_or_arguments.names[0]) else: arguments_strategies = [ strategies.sampled_from(arg.choices) if arg.choices is not None else Strategies.WORDS for arg in subcommand_or_arguments ] command_line.extend( draw( strategies.tuples(*arguments_strategies), f"section {i} arguments", ) ) return cli_machinery.ParseState( tuple(command_line), subcommand, ) @staticmethod @strategies.composite def normalizable_parser_states( draw: strategies.DrawFn, /, subcommand_and_symbolic_command_line: tuple[ cli_machinery.CLISubcommand, list[ tuple[ tuple[cli_machinery.CLIOption, ...], tuple[cli_machinery.CLIArgument, ...] | cli_machinery.CLISubcommand, ] ], ] | None = None, *, allow_eager: bool = True, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> ParseStateAndExpectedResult: subcommand, command_line_symbolic_sections = ( subcommand_and_symbolic_command_line if subcommand_and_symbolic_command_line is not None else draw( Strategies.subcommand_and_symbolic_command_line( allow_eager=allow_eager, max_option_groups=max_option_groups, max_positionals=max_positionals, ), "subcommand/symbolic command-line", ) ) command_line: list[str] = [] first_section_normalized_command_line: list[str] = [] for i, section in enumerate(command_line_symbolic_sections): options, subcommand_or_arguments = section if options: # pragma: no branch option_and_argument_pairs = draw( Strategies.any_option_and_argument_pairs( *options, in_order=True, normalized=False, ), f"section {i} raw option/argument pairs", ) drawn_options_and_arguments = ( draw( Strategies.any_options( strategies.just(option_and_argument_pairs), normalized=False, ), f"section {i} options", ) if options else [] ) command_line.extend(drawn_options_and_arguments) if i == 0: assert len(option_and_argument_pairs) == len(options) for opt, pair in zip(options, option_and_argument_pairs): opt_name = opt.names[0] if len(pair) > 1: arg = pair[1] first_section_normalized_command_line.append( f"{opt_name}={arg}" ) else: first_section_normalized_command_line.append( opt_name ) else: first_section_normalized_command_line.extend( drawn_options_and_arguments ) if isinstance( subcommand_or_arguments, ( cli_machinery.CLITerminalSubcommand, cli_machinery.CLICompositeSubcommand, ), ): subcommand_name = subcommand_or_arguments.names[0] command_line.append(subcommand_name) if i == 0: first_section_normalized_command_line.append("--") first_section_normalized_command_line.append(subcommand_name) else: arguments_strategies = [ strategies.sampled_from(arg.choices) if arg.choices is not None else Strategies.WORDS for arg in subcommand_or_arguments ] argument_values = draw( strategies.tuples(*arguments_strategies), f"section {i} arguments", ) command_line.extend(argument_values) if i == 0: first_section_normalized_command_line.append("--") first_section_normalized_command_line.extend(argument_values) old_state = cli_machinery.ParseState(tuple(command_line), subcommand) new_state = cli_machinery.ParseState( tuple(first_section_normalized_command_line), subcommand ) result = cli_machinery.ParseSuccess(new_state, None) return ParseStateAndExpectedResult(old_state, result) @staticmethod @strategies.composite def scan_for_eager_options_parser_states( draw: strategies.DrawFn, /, subcommand_and_symbolic_command_line: tuple[ cli_machinery.CLISubcommand, list[ tuple[ tuple[cli_machinery.CLIOption, ...], tuple[cli_machinery.CLIArgument, ...] | cli_machinery.CLISubcommand, ] ], ] | None = None, *, allow_eager: bool = True, max_option_groups: int = NUM_NAMES, max_positionals: int = NUM_NAMES, ) -> ParseStateAndExpectedResult: subcommand, command_line_symbolic_sections = ( subcommand_and_symbolic_command_line if subcommand_and_symbolic_command_line is not None else draw( Strategies.subcommand_and_symbolic_command_line( allow_eager=allow_eager, max_option_groups=max_option_groups, max_positionals=max_positionals, ), "subcommand/symbolic command-line", ) ) command_line: list[str] = [] equivalent_command_line: list[str] = [] parse_result: str | None = None for i, section in enumerate(command_line_symbolic_sections): options, subcommand_or_arguments = section if options: # pragma: no branch option_and_argument_pairs = draw( Strategies.any_option_and_argument_pairs( *options, in_order=True, normalized=True, ), f"section {i} raw option/argument pairs", ) drawn_options_and_arguments = ( draw( Strategies.any_options( strategies.just(option_and_argument_pairs), normalized=True, ), f"section {i} options", ) if options else [] ) command_line.extend(drawn_options_and_arguments) if i == 0: assert len(option_and_argument_pairs) == len(options) for opt, pair in zip(options, option_and_argument_pairs): token = ( f"{opt.names[0]}={pair[1]}" if len(pair) > 1 else opt.names[0] ) if opt.eager: equivalent_command_line.clear() parse_result = token equivalent_command_line.append(token) if parse_result is not None: break elif parse_result is None: equivalent_command_line.extend(drawn_options_and_arguments) if parse_result is not None: pass elif isinstance( subcommand_or_arguments, ( cli_machinery.CLITerminalSubcommand, cli_machinery.CLICompositeSubcommand, ), ): command_line.append("--") equivalent_command_line.append("--") subcommand_name = subcommand_or_arguments.names[0] command_line.append(subcommand_name) equivalent_command_line.append(subcommand_name) else: command_line.append("--") equivalent_command_line.append("--") arguments_strategies = [ strategies.sampled_from(arg.choices) if arg.choices is not None else Strategies.WORDS for arg in subcommand_or_arguments ] argument_values = draw( strategies.tuples(*arguments_strategies), f"section {i} arguments", ) command_line.extend(argument_values) equivalent_command_line.extend(argument_values) old_state = cli_machinery.ParseState(tuple(command_line), subcommand) new_state = cli_machinery.ParseState( tuple(equivalent_command_line), subcommand ) result = ( cli_machinery.ParseEarlyExit(new_state, parse_result) if parse_result is not None else cli_machinery.ParseSuccess(new_state, parse_result) ) return ParseStateAndExpectedResult(old_state, result) strategies.register_type_strategy( cli_machinery.CLIOption, Strategies.options(), ) strategies.register_type_strategy( cli_machinery.CLIOptionGroup, Strategies.option_groups(), ) strategies.register_type_strategy( cli_machinery.CLITerminalSubcommand, Strategies.terminal_subcommands(), ) strategies.register_type_strategy( cli_machinery.CLICompositeSubcommand, Strategies.composite_subcommands(Strategies.subcommands()), ) strategies.register_type_strategy( cli_machinery.ParseState, Strategies.parse_states(), ) def tokenize_version_output_item_listing( line: str, /, *, is_alias_annotation: bool = False, ) -> Generator[str, None, None]: """Yield the next feature in a `--version` feature listing. This is a regular expression-based parser (alluded to in [`parse_version_output`][]) that yields the next item name or alias it encounters. (The output is indistinguishable for those two types.) We assume that continuation lines have already been reversed, i.e., that the whole input is on a single line. We further assume that the listing header has been removed, i.e., we are only processing the raw list items. Args: line: The input line, normalized as explained above. is_alias_annotation: If true, then the input line is contents of an alias listing, and itself does not support aliases. Otherwise, aliases are supported. Yields: The next item name or alias. There is no way to distinguish these cases based on output alone. """ chunk_re = re.compile( r""" # the item name (?P[^,()]+) # whitespace (?:[ ]*) # the terminator (?:,[ ]*|$) """ if is_alias_annotation else r""" # the item name (?P[^,()]+) # alias list (?:[ ]+ # alias marker \( {aliases_marker!s} [ ]+ # the alias entries (?P[^()]+) \) )? # the terminator (?:,[ ]*|\.$) """.format( aliases_marker=cli_messages.TranslatedString( cli_messages.Label.FEATURE_ITEM_ALIASES ) ), re.VERBOSE, ) rest = line.strip() while (match := chunk_re.match(rest)) is not None: name = match.group("name") assert name, "item listing tokenizer is inconsistent" assert name.strip() == name, "item listing tokenizer is inconsistent" yield name if not is_alias_annotation and match.group("alias_list"): alias_list = match.group("alias_list") assert alias_list, "item listing tokenizer is inconsistent" assert alias_list.strip(), "item listing tokenizer is inconsistent" assert alias_list.lstrip() == alias_list, ( "item listing tokenizer is inconsistent" ) yield from tokenize_version_output_item_listing( alias_list.strip(), is_alias_annotation=True ) rest = rest.removeprefix(match.group(0)) if rest: # pragma: no cover [defensive] msg = f"Trailing unparsable junk on {line!r}: {rest!r}" raise ValueError(msg) def parse_version_output( # noqa: C901 version_output: str, /, *, prog_name: str | None = cli_messages.PROG_NAME, version: str | None = cli_messages.VERSION, ) -> VersionOutputData: r"""Parse the output of the `--version` option. The version output contains two paragraphs. The first paragraph details the version number, and the version number of any major libraries in use. The second paragraph details known and supported passphrase derivation schemes, foreign configuration formats, subcommands, SSH agent socket providers and PEP 508 package extras. For the schemes, formats and socket providers, there is a "supported" line for supported items, and a "known" line for known but currently unsupported items (usually because of missing dependencies), either of which may be empty and thus omitted. For extras, only active items are shown, and there is a separate message for the "no extras active" case. Items may be followed by a list of aliases, explicitly marked as such. Item lists may be spilled across multiple lines, but only at item boundaries. (The alias list counts as part of the same item.) The continuation lines are then indented. The list of aliases is formatted as ` (aliases: , )`. Only one level of aliases is supported, and neither `` nor `` must contain parentheses. (Brackets and braces are discouraged, but not expressly forbidden.) Args: version_output: The version output text to parse. prog_name: The program name to assert, defaulting to the true program name, `derivepassphrase`. Set to `None` to disable this check. version: The program version to assert, defaulting to the true current version of `derivepassphrase`. Set to `None` to disable this check. Examples: See [`Parametrize.VERSION_OUTPUT_DATA`][]. See also: * [`tokenize_version_output_item_listing`][] """ paragraphs: list[list[str]] = [] paragraph: list[str] = [] for line in version_output.splitlines(keepends=False): if not line.strip(): if paragraph: paragraphs.append(paragraph.copy()) paragraph.clear() elif paragraph and line.lstrip() != line: paragraph[-1] = f"{paragraph[-1]} {line.lstrip()}" else: paragraph.append(line) if paragraph: # pragma: no branch paragraphs.append(paragraph.copy()) paragraph.clear() assert paragraphs, ( f"expected at least one paragraph of version output: {paragraphs!r}" ) assert prog_name is None or prog_name in paragraphs[0][0], ( f"first version output line should mention " f"{prog_name}: {paragraphs[0][0]!r}" ) assert version is None or version in paragraphs[0][0], ( f"first version output line should mention the version number " f"{version}: {paragraphs[0][0]!r}" ) schemes: dict[str, bool] = {} formats: dict[str, bool] = {} subcommands: set[str] = set() extras: set[str] = set() features: dict[str, bool] = {} ssh_agent_socket_providers: dict[str, bool] = {} if len(paragraphs) < 2: # pragma: no cover return VersionOutputData( derivation_schemes=schemes, foreign_configuration_formats=formats, subcommands=frozenset(subcommands), extras=frozenset(extras), features=features, ssh_agent_socket_providers=ssh_agent_socket_providers, ) for line in paragraphs[1]: line_type, _, value = line.partition(":") if line_type == line: continue for item_ in tokenize_version_output_item_listing(value): item = item_.strip() if line_type == KnownLineType.SUPPORTED_FOREIGN_CONFS: formats[item] = True elif line_type == KnownLineType.UNAVAILABLE_FOREIGN_CONFS: formats[item] = False elif line_type == KnownLineType.SUPPORTED_SCHEMES: schemes[item] = True elif line_type == KnownLineType.UNAVAILABLE_SCHEMES: schemes[item] = False elif line_type == KnownLineType.SUPPORTED_SUBCOMMANDS: subcommands.add(item) elif line_type == KnownLineType.ENABLED_EXTRAS: extras.add(item) elif line_type == KnownLineType.SUPPORTED_FEATURES: features[item] = True elif line_type == KnownLineType.UNAVAILABLE_FEATURES: features[item] = False elif ( line_type == KnownLineType.SUPPORTED_SSH_AGENT_SOCKET_PROVIDERS ): ssh_agent_socket_providers[item] = True elif ( line_type == KnownLineType.UNAVAILABLE_SSH_AGENT_SOCKET_PROVIDERS ): ssh_agent_socket_providers[item] = False else: raise AssertionError( # noqa: TRY003 f"Unknown version info line type: {line_type!r}" # noqa: EM102 ) return VersionOutputData( derivation_schemes=schemes, foreign_configuration_formats=formats, subcommands=frozenset(subcommands), extras=frozenset(extras), features=features, ssh_agent_socket_providers=ssh_agent_socket_providers, ) class Test001VersionOutputParser: """Tests for the `--version` output parser.""" @Parametrize.MASK_PROG_NAME @Parametrize.MASK_VERSION @Parametrize.VERSION_OUTPUT_DATA def test_parse_version_output( self, version_output: str, prog_name: str | None, version: str | None, mask_prog_name: bool, mask_version: bool, expected_parse: VersionOutputData, ) -> None: """The parsing machinery for expected version output data works.""" prog_name = None if mask_prog_name else prog_name version = None if mask_version else version assert ( parse_version_output( version_output, prog_name=prog_name, version=version ) == expected_parse ) class Test010CLIMachinery: """Tests for the CLI machinery.""" @hypothesis.given(starting_state=Strategies.parse_states(normalized=True)) def test_normalized_starting_states_are_normalized( self, starting_state: cli_machinery.ParseState, ) -> None: command_line = starting_state.command_line canon_map = starting_state.subcommand.canon_map for token in command_line: tokentype = cli_machinery.CommandLineTokenType.classify(token) assert ( tokentype != cli_machinery.CommandLineTokenType.SHORT_OPTION ), f"Short option {token!r} not allowed in normalized command-line" assert ( tokentype != cli_machinery.CommandLineTokenType.POSITIONAL ), ( f"Positional argument {token!r} not allowed " "in normalized command-line without preceding " "end-of-options token" ) if tokentype == cli_machinery.CommandLineTokenType.END_OF_OPTIONS: break assert tokentype == cli_machinery.CommandLineTokenType.LONG_OPTION if "=" not in token: opt = cast("cli_machinery.CLIOption", canon_map[token]) assert not opt.has_argument, ( f"Separated argument for option {token!r}=... not allowed " "in normalized command-line" ) @hypothesis.given(starting_state=Strategies.parse_states(normalized=True)) def test_normalize_options_stage_on_normalized_command_lines( self, starting_state: cli_machinery.ParseState, ) -> None: result = cli_machinery.normalize_options(starting_state) assert result == cli_machinery.ParseResult.unit(starting_state) @hypothesis.given(data_tuple=Strategies.normalizable_parser_states()) def test_normalize_options_stage( self, data_tuple: ParseStateAndExpectedResult, ) -> None: actual_result = cli_machinery.normalize_options( data_tuple.starting_state ) assert actual_result.state == data_tuple.expected_result.state assert actual_result == data_tuple.expected_result @hypothesis.given( data_tuple=Strategies.scan_for_eager_options_parser_states() ) def test_scan_for_eager_options_parser_stage( self, data_tuple: ParseStateAndExpectedResult, ) -> None: actual_result = cli_machinery.scan_for_eager_options( data_tuple.starting_state ) assert actual_result.state == data_tuple.expected_result.state assert actual_result == data_tuple.expected_result class TestHelpOutput: """Tests for all command-line interfaces' `--help` output.""" # TODO(the-13th-letter): Do we actually need this? What should we # check for? @Parametrize.HELP_OUTPUT_COMMAND_LINE def test_help_output( self, command_line: list[str], expected_lines: list[str], ) -> None: """The respective help text contains certain expected phrases. TODO: Do we actually need this? What should we check for? """ runner = machinery.CliRunner(mix_stderr=False) # TODO(the-13th-letter): Rewrite using parenthesized # with-statements. # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 with contextlib.ExitStack() as stack: monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) stack.enter_context( pytest_machinery.isolated_config( monkeypatch=monkeypatch, runner=runner, ) ) result = runner.invoke( cli.derivepassphrase, [*command_line, "--help"], catch_exceptions=False, ) for line in expected_lines: assert result.clean_exit(empty_stderr=True, output=line), ( "expected clean exit, and known help text" ) @Parametrize.COMMAND_NON_EAGER_ARGUMENTS @Parametrize.EAGER_ARGUMENTS def test_eager_options( self, command: list[str], arguments: list[str], non_eager_arguments: list[str], ) -> None: """Eager options terminate option and argument processing.""" runner = machinery.CliRunner(mix_stderr=False) # TODO(the-13th-letter): Rewrite using parenthesized # with-statements. # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 with contextlib.ExitStack() as stack: monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) stack.enter_context( pytest_machinery.isolated_config( monkeypatch=monkeypatch, runner=runner, ) ) result = runner.invoke( cli.derivepassphrase, [*command, *arguments, *non_eager_arguments], catch_exceptions=False, ) assert result.clean_exit(empty_stderr=True), "expected clean exit" @Parametrize.ISATTY @Parametrize.COLORFUL_COMMAND_INPUT def test_automatic_color_mode( self, isatty: bool, command_line: list[str], input: str | None, ) -> None: """Auto-detect if color should be used. (The answer currently is always no. See the [`conventional-configurable-text-styling` wishlist entry][WISHLIST_ENTRY].) [WISHLIST_ENTRY]: https://the13thletter.info/derivepassphrase/0.x/wishlist/conventional-configurable-text-styling/ """ color = False runner = machinery.CliRunner(mix_stderr=False) # TODO(the-13th-letter): Rewrite using parenthesized # with-statements. # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 with contextlib.ExitStack() as stack: monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) stack.enter_context( pytest_machinery.isolated_config( monkeypatch=monkeypatch, runner=runner, ) ) result = runner.invoke( cli.derivepassphrase, command_line, input=input, catch_exceptions=False, color=isatty, ) assert ( not color or "\x1b[0m" in result.stderr or "\x1b[m" in result.stderr ), "Expected color, but found no ANSI reset sequence" assert color or "\x1b[" not in result.stderr, ( "Expected no color, but found an ANSI control sequence" ) class TestVersionOutput: """Tests for all command-line interfaces' `--version` output.""" def _test( self, command_line: list[str], ) -> VersionOutputData: runner = machinery.CliRunner(mix_stderr=False) # TODO(the-13th-letter): Rewrite using parenthesized # with-statements. # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 with contextlib.ExitStack() as stack: monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) stack.enter_context( pytest_machinery.isolated_config( monkeypatch=monkeypatch, runner=runner, ) ) result = runner.invoke( cli.derivepassphrase, [*command_line, "--version"], catch_exceptions=False, ) assert result.clean_exit(empty_stderr=True), "expected clean exit" assert result.stdout.strip(), "expected version output" return parse_version_output(result.stdout) def test_derivepassphrase_version_option_output( self, ) -> None: """The version output states supported features. The version output is parsed using [`parse_version_output`][]. Format examples can be found in [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the top-level `derivepassphrase` command, the output should contain the known and supported derivation schemes, and a list of subcommands. As a side effect, [`parse_version_output`][] guarantees that the first line contains both the correct program name as well as the correct program version number. """ version_data = self._test([]) actually_known_schemes = dict.fromkeys(_types.DerivationScheme, True) subcommands = set(_types.Subcommand) assert version_data.derivation_schemes == actually_known_schemes assert not version_data.foreign_configuration_formats assert version_data.subcommands == subcommands assert not version_data.features assert not version_data.extras def test_export_version_option_output( self, ) -> None: """The version output states supported features. The version output is parsed using [`parse_version_output`][]. Format examples can be found in [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the `export` command, the output should contain the known foreign configuration formats (but not marked as supported), and a list of subcommands. As a side effect, [`parse_version_output`][] guarantees that the first line contains both the correct program name as well as the correct program version number. """ version_data = self._test(["export"]) actually_known_formats: dict[str, bool] = { _types.ForeignConfigurationFormat.VAULT_STOREROOM: False, _types.ForeignConfigurationFormat.VAULT_V02: False, _types.ForeignConfigurationFormat.VAULT_V03: False, } subcommands = set(_types.ExportSubcommand) assert not version_data.derivation_schemes assert ( version_data.foreign_configuration_formats == actually_known_formats ) assert version_data.subcommands == subcommands assert not version_data.features assert not version_data.extras def test_export_vault_version_option_output( self, ) -> None: """The version output states supported features. The version output is parsed using [`parse_version_output`][]. Format examples can be found in [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the `export vault` subcommand, the output should contain the vault-specific subset of the known or supported foreign configuration formats, and a list of available PEP 508 extras. As a side effect, [`parse_version_output`][] guarantees that the first line contains both the correct program name as well as the correct program version number. """ version_data = self._test(["export", "vault"]) actually_known_formats: dict[str, bool] = {} actually_enabled_extras: set[str] = set() with contextlib.suppress(ModuleNotFoundError): from derivepassphrase.exporter import storeroom, vault_native # noqa: I001,PLC0415 actually_known_formats.update({ _types.ForeignConfigurationFormat.VAULT_STOREROOM: not storeroom.STUBBED, _types.ForeignConfigurationFormat.VAULT_V02: not vault_native.STUBBED, _types.ForeignConfigurationFormat.VAULT_V03: not vault_native.STUBBED, }) with contextlib.suppress(ModuleNotFoundError): import cryptography # noqa: F401,PLC0415 actually_enabled_extras.add(_types.PEP508Extra.EXPORT) assert not version_data.derivation_schemes assert ( version_data.foreign_configuration_formats == actually_known_formats ) assert not version_data.subcommands assert not version_data.features assert version_data.extras == actually_enabled_extras def test_vault_version_option_output( self, ) -> None: """The version output states supported features. The version output is parsed using [`parse_version_output`][]. Format examples can be found in [`Parametrize.VERSION_OUTPUT_DATA`][]. Specifically, for the vault command, the output should not contain anything beyond the first paragraph. As a side effect, [`parse_version_output`][] guarantees that the first line contains both the correct program name as well as the correct program version number. """ version_data = self._test(["vault"]) ssh_key_supported = True def react_to_notimplementederror( _exc: BaseException, ) -> None: # pragma: no cover[unused] nonlocal ssh_key_supported ssh_key_supported = False with exceptiongroup.catch({ # noqa: SIM117 NotImplementedError: react_to_notimplementederror, Exception: lambda *_args: None, }): with ssh_agent.SSHAgentClient.ensure_agent_subcontext(): pass features: dict[str, bool] = { _types.Feature.SSH_KEY: ssh_key_supported, } assert not version_data.derivation_schemes assert not version_data.foreign_configuration_formats assert not version_data.subcommands assert version_data.features == features assert not version_data.extras