zipctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. zipctl/__init__.py +108 -0
  2. zipctl/__main__.py +5 -0
  3. zipctl/cli/__init__.py +108 -0
  4. zipctl/cli/archive.py +23 -0
  5. zipctl/cli/atomic.py +77 -0
  6. zipctl/cli/commands/__init__.py +8 -0
  7. zipctl/cli/commands/check_password.py +133 -0
  8. zipctl/cli/commands/create.py +374 -0
  9. zipctl/cli/commands/decrypt.py +70 -0
  10. zipctl/cli/commands/encrypt.py +102 -0
  11. zipctl/cli/commands/extract.py +262 -0
  12. zipctl/cli/commands/helpers/__init__.py +1 -0
  13. zipctl/cli/commands/helpers/collect.py +225 -0
  14. zipctl/cli/commands/helpers/command.py +119 -0
  15. zipctl/cli/commands/helpers/copying/__init__.py +1 -0
  16. zipctl/cli/commands/helpers/copying/copy.py +211 -0
  17. zipctl/cli/commands/helpers/copying/options.py +80 -0
  18. zipctl/cli/commands/helpers/copying/report.py +63 -0
  19. zipctl/cli/commands/helpers/copying/targets.py +76 -0
  20. zipctl/cli/commands/helpers/copying/verify.py +111 -0
  21. zipctl/cli/commands/helpers/create_report.py +154 -0
  22. zipctl/cli/commands/helpers/encryption/__init__.py +1 -0
  23. zipctl/cli/commands/helpers/encryption/options.py +192 -0
  24. zipctl/cli/commands/helpers/encryption/plan.py +119 -0
  25. zipctl/cli/commands/helpers/encryption/spec.py +256 -0
  26. zipctl/cli/commands/helpers/extract_report.py +148 -0
  27. zipctl/cli/commands/helpers/output_options.py +55 -0
  28. zipctl/cli/commands/helpers/passwords/__init__.py +1 -0
  29. zipctl/cli/commands/helpers/passwords/options.py +143 -0
  30. zipctl/cli/commands/helpers/passwords/pool.py +116 -0
  31. zipctl/cli/commands/helpers/passwords/sources.py +144 -0
  32. zipctl/cli/commands/helpers/policy_options.py +64 -0
  33. zipctl/cli/commands/helpers/progress.py +215 -0
  34. zipctl/cli/commands/helpers/selection.py +96 -0
  35. zipctl/cli/commands/helpers/sources.py +32 -0
  36. zipctl/cli/commands/inspect.py +145 -0
  37. zipctl/cli/commands/list.py +166 -0
  38. zipctl/cli/commands/policy.py +104 -0
  39. zipctl/cli/commands/rewrite.py +113 -0
  40. zipctl/cli/commands/test.py +154 -0
  41. zipctl/cli/context.py +57 -0
  42. zipctl/cli/errors.py +58 -0
  43. zipctl/cli/formatter.py +62 -0
  44. zipctl/cli/methods.py +136 -0
  45. zipctl/cli/output.py +138 -0
  46. zipctl/cli/parser.py +197 -0
  47. zipctl/compression/__init__.py +129 -0
  48. zipctl/compression/bz2.py +99 -0
  49. zipctl/compression/deflate.py +136 -0
  50. zipctl/compression/lzma.py +238 -0
  51. zipctl/compression/methods.py +176 -0
  52. zipctl/compression/stored.py +14 -0
  53. zipctl/compression/zstd.py +141 -0
  54. zipctl/cryptography/__init__.py +41 -0
  55. zipctl/cryptography/aes.py +500 -0
  56. zipctl/cryptography/base.py +163 -0
  57. zipctl/cryptography/zipcrypto.py +251 -0
  58. zipctl/exceptions.py +39 -0
  59. zipctl/py.typed +0 -0
  60. zipctl/zipfile/__init__.py +1 -0
  61. zipctl/zipfile/assessment.py +41 -0
  62. zipctl/zipfile/assessor.py +151 -0
  63. zipctl/zipfile/exceptions.py +27 -0
  64. zipctl/zipfile/ext.py +782 -0
  65. zipctl/zipfile/extract.py +186 -0
  66. zipctl/zipfile/file.py +1500 -0
  67. zipctl/zipfile/info.py +1127 -0
  68. zipctl/zipfile/inspection.py +127 -0
  69. zipctl/zipfile/io_wrappers.py +193 -0
  70. zipctl/zipfile/materialize.py +344 -0
  71. zipctl/zipfile/password.py +87 -0
  72. zipctl/zipfile/path/__init__.py +523 -0
  73. zipctl/zipfile/path/glob.py +104 -0
  74. zipctl/zipfile/policy_config.py +426 -0
  75. zipctl/zipfile/policy_extraction.py +267 -0
  76. zipctl/zipfile/progress.py +133 -0
  77. zipctl/zipfile/records.py +504 -0
  78. zipctl/zipfile/secure_fs.py +128 -0
  79. zipctl/zipfile/shared.py +104 -0
  80. zipctl/zipfile/validators.py +392 -0
  81. zipctl/zipfile/write.py +311 -0
  82. zipctl/zipfile/write_coordinator.py +111 -0
  83. zipctl-0.1.0.dist-info/METADATA +730 -0
  84. zipctl-0.1.0.dist-info/RECORD +91 -0
  85. zipctl-0.1.0.dist-info/WHEEL +4 -0
  86. zipctl-0.1.0.dist-info/entry_points.txt +2 -0
  87. zipctl-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zipctl-0.1.0.dist-info/licenses/NOTICE +32 -0
  89. zipctl-0.1.0.dist-info/licenses/licenses/CPYTHON-3.14.3.txt +277 -0
  90. zipctl-0.1.0.dist-info/licenses/licenses/JetBrainsMono-OFL.txt +93 -0
  91. zipctl-0.1.0.dist-info/licenses/licenses/pyzipper-MIT.txt +22 -0
zipctl/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ from zipctl.compression import (
2
+ ZIP_BZIP2,
3
+ ZIP_DEFLATED,
4
+ ZIP_LZMA,
5
+ ZIP_STORED,
6
+ ZIP_ZSTANDARD,
7
+ Registry,
8
+ )
9
+ from zipctl.cryptography import WZ_AES, WZ_AES_V1, WZ_AES_V2, ZIP_CRYPTO
10
+ from zipctl.exceptions import BadPassword, PasswordError, PasswordRequired
11
+ from zipctl.zipfile.assessment import ArchiveAssessment, ExtractionContext
12
+ from zipctl.zipfile.exceptions import (
13
+ ExtractionFailure,
14
+ ExtractionMaterializationError,
15
+ ExtractionQuotaExceeded,
16
+ ExtractionSecurityError,
17
+ )
18
+ from zipctl.zipfile.extract import (
19
+ ExtractionError,
20
+ ExtractMemberResult,
21
+ ExtractPolicy,
22
+ ExtractPolicyRule,
23
+ ExtractResult,
24
+ ExtractViolation,
25
+ MemberAssessment,
26
+ MemberStatus,
27
+ OverwritePolicy,
28
+ ViolationAction,
29
+ )
30
+ from zipctl.zipfile.file import (
31
+ INHERIT_ENCRYPTION,
32
+ EncryptionOverride,
33
+ PasswordProvider,
34
+ ZipFile,
35
+ ZipFileExtra,
36
+ is_zipfile,
37
+ )
38
+ from zipctl.zipfile.info import WzAesExtra
39
+ from zipctl.zipfile.inspection import InspectionMember, InspectionResult
40
+ from zipctl.zipfile.password import (
41
+ MemberPasswordCheck,
42
+ PasswordCheckResult,
43
+ PasswordStatus,
44
+ )
45
+ from zipctl.zipfile.path import Path
46
+ from zipctl.zipfile.policy_config import (
47
+ PolicyConfigError,
48
+ PolicyIssue,
49
+ policy_from_json,
50
+ policy_from_mapping,
51
+ policy_to_json,
52
+ policy_to_mapping,
53
+ )
54
+ from zipctl.zipfile.progress import ProgressCallback, ProgressEvent, ProgressPhase
55
+
56
+ __all__ = [
57
+ "WZ_AES",
58
+ "WZ_AES_V1",
59
+ "WZ_AES_V2",
60
+ "WzAesExtra",
61
+ "ZipFileExtra",
62
+ "ZIP_CRYPTO",
63
+ "ZIP_STORED",
64
+ "ZIP_DEFLATED",
65
+ "ZIP_BZIP2",
66
+ "ZIP_LZMA",
67
+ "ZIP_ZSTANDARD",
68
+ "Registry",
69
+ "ZipFile",
70
+ "is_zipfile",
71
+ "INHERIT_ENCRYPTION",
72
+ "EncryptionOverride",
73
+ "ExtractMemberResult",
74
+ "ExtractPolicy",
75
+ "ExtractPolicyRule",
76
+ "ExtractResult",
77
+ "ExtractViolation",
78
+ "ExtractionError",
79
+ "ExtractionFailure",
80
+ "ExtractionMaterializationError",
81
+ "ExtractionQuotaExceeded",
82
+ "ExtractionSecurityError",
83
+ "MemberStatus",
84
+ "MemberAssessment",
85
+ "ArchiveAssessment",
86
+ "BadPassword",
87
+ "MemberPasswordCheck",
88
+ "PasswordCheckResult",
89
+ "PasswordError",
90
+ "PasswordProvider",
91
+ "PasswordRequired",
92
+ "PasswordStatus",
93
+ "PolicyConfigError",
94
+ "PolicyIssue",
95
+ "ProgressCallback",
96
+ "ProgressEvent",
97
+ "ProgressPhase",
98
+ "policy_from_json",
99
+ "policy_from_mapping",
100
+ "policy_to_json",
101
+ "policy_to_mapping",
102
+ "ExtractionContext",
103
+ "OverwritePolicy",
104
+ "ViolationAction",
105
+ "InspectionMember",
106
+ "InspectionResult",
107
+ "Path",
108
+ ]
zipctl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run the command-line interface with ``python -m zipctl``."""
2
+
3
+ from zipctl.cli import main
4
+
5
+ raise SystemExit(main())
zipctl/cli/__init__.py ADDED
@@ -0,0 +1,108 @@
1
+ """Command-line interface: ``zipctl`` and ``python -m zipctl``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import traceback
9
+ from collections.abc import Sequence
10
+
11
+ from zipctl.cli.commands.helpers.command import Handler
12
+ from zipctl.cli.context import Context
13
+ from zipctl.cli.errors import (
14
+ EXIT_BROKEN_PIPE,
15
+ EXIT_FAILURE,
16
+ EXIT_INTERRUPTED,
17
+ EXIT_OK,
18
+ CliError,
19
+ os_error_filename,
20
+ os_error_text,
21
+ )
22
+ from zipctl.cli.output import printable, write_json
23
+ from zipctl.cli.parser import build_parser
24
+
25
+ __all__ = ["main"]
26
+
27
+
28
+ class _Parsed(argparse.Namespace):
29
+ """The parsed arguments, with the handler the chosen command registered."""
30
+
31
+ handler: Handler # pyright: ignore[reportUninitializedInstanceVariable] # set by argparse
32
+
33
+
34
+ def _exit_code(code: object) -> int:
35
+ if code is None:
36
+ return EXIT_OK
37
+ return code if isinstance(code, int) else 1
38
+
39
+
40
+ def _quiet_broken_pipe() -> None:
41
+ """Stop Python complaining again about the closed pipe when it exits."""
42
+ try:
43
+ devnull = os.open(os.devnull, os.O_WRONLY)
44
+ os.dup2(devnull, sys.stdout.fileno())
45
+ except (OSError, ValueError):
46
+ pass
47
+
48
+
49
+ def _fail(
50
+ ctx: Context,
51
+ args: argparse.Namespace,
52
+ exc: Exception,
53
+ message: str,
54
+ code: int,
55
+ details: tuple[str, ...] = (),
56
+ ) -> int:
57
+ """Report *message* on standard error (and as JSON with ``--json``)."""
58
+ if ctx.environ.get("ZIPCTL_DEBUG"):
59
+ exc.__suppress_context__ = False # show the error a ``from None`` hid
60
+ traceback.print_exception(exc, file=ctx.stderr)
61
+ ctx.err(f"zipctl: error: {message}")
62
+ for line in details:
63
+ ctx.err(f" {line}")
64
+ wants_json: bool = getattr(args, "json", False) # not every command has --json
65
+ if wants_json:
66
+ error = {"ok": False, "error": message, "code": code, "details": list(details)}
67
+ write_json(ctx.stdout, error)
68
+ return code
69
+
70
+
71
+ def main(argv: Sequence[str] | None = None) -> int:
72
+ """Run the CLI with *argv* (default: ``sys.argv[1:]``); return the exit code."""
73
+ ctx = Context.from_process()
74
+ for stream in (ctx.stdout, ctx.stderr):
75
+ reconfigure = getattr(stream, "reconfigure", None)
76
+ if reconfigure is not None:
77
+ reconfigure(errors="backslashreplace")
78
+
79
+ parser = build_parser()
80
+ try:
81
+ args = parser.parse_args(
82
+ sys.argv[1:] if argv is None else argv, namespace=_Parsed()
83
+ )
84
+ except SystemExit as exc:
85
+ return _exit_code(exc.code)
86
+
87
+ try:
88
+ code = args.handler(args, ctx)
89
+ ctx.stdout.flush()
90
+ return int(code)
91
+ except CliError as exc:
92
+ return _fail(ctx, args, exc, exc.message, exc.code, exc.details)
93
+ except KeyboardInterrupt:
94
+ ctx.err("zipctl: interrupted")
95
+ return EXIT_INTERRUPTED
96
+ except BrokenPipeError:
97
+ _quiet_broken_pipe()
98
+ return EXIT_BROKEN_PIPE
99
+ except OSError as exc: # whatever a command did not translate itself
100
+ filename = os_error_filename(exc)
101
+ where = f"{printable(filename)}: " if filename else ""
102
+ message = f"{where}{printable(os_error_text(exc))}"
103
+ return _fail(ctx, args, exc, message, EXIT_FAILURE)
104
+ except Exception as exc: # a bug: name it, and offer the traceback
105
+ message = f"unexpected {type(exc).__name__}: {printable(str(exc))}"
106
+ if not ctx.environ.get("ZIPCTL_DEBUG"):
107
+ message += " (set ZIPCTL_DEBUG=1 for the traceback)"
108
+ return _fail(ctx, args, exc, message, EXIT_FAILURE)
zipctl/cli/archive.py ADDED
@@ -0,0 +1,23 @@
1
+ """Opening archives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zipctl.cli.errors import CliError, os_error_text
6
+ from zipctl.cli.output import printable
7
+ from zipctl.exceptions import BadZipFile
8
+ from zipctl.zipfile.file import ZipFile
9
+
10
+ __all__ = ["CHUNK", "open_archive"]
11
+
12
+ CHUNK = 1 << 20 # bytes read at a time when streaming a member
13
+
14
+
15
+ def open_archive(path: str) -> ZipFile:
16
+ try:
17
+ return ZipFile(path)
18
+ except BadZipFile:
19
+ raise CliError(f"{printable(path)}: not a valid ZIP archive") from None
20
+ except NotImplementedError as exc:
21
+ raise CliError(f"{printable(path)}: unsupported ZIP feature ({exc})") from None
22
+ except OSError as exc:
23
+ raise CliError(f"cannot open {printable(path)}: {os_error_text(exc)}") from None
zipctl/cli/atomic.py ADDED
@@ -0,0 +1,77 @@
1
+ """Writing an archive so that a failed run leaves nothing behind."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ import shutil
8
+ from collections.abc import Generator
9
+ from contextlib import contextmanager
10
+
11
+ from zipctl.cli.errors import CliError, os_error_text
12
+ from zipctl.cli.output import printable
13
+
14
+ __all__ = ["replacing"]
15
+
16
+
17
+ def _sync_directory(directory: str) -> None:
18
+ """Make the rename durable; best effort (not every platform can do it)."""
19
+ try:
20
+ handle = os.open(directory, os.O_RDONLY)
21
+ try:
22
+ os.fsync(handle)
23
+ finally:
24
+ os.close(handle)
25
+ except OSError:
26
+ pass
27
+
28
+
29
+ @contextmanager
30
+ def replacing(path: str, *, overwrite: bool, seed: bool = False) -> Generator[str]:
31
+ """Yield a scratch path beside *path*; move it onto *path* if the block succeeds.
32
+
33
+ The scratch file is removed if the block raises (Ctrl-C included), so *path*
34
+ is either untouched or complete. An existing *path* keeps its permission
35
+ bits. With *seed*, the scratch file starts as a copy of it (for
36
+ appending). Unless *overwrite*, an
37
+ existing *path* is refused, both up front and again just before the move.
38
+ """
39
+ target = os.path.realpath(path) # through a symlink, so the link survives
40
+ exists = os.path.lexists(target)
41
+ if exists and not overwrite:
42
+ raise CliError(f"{printable(path)} already exists (use --force to replace it)")
43
+ directory = os.path.dirname(target)
44
+ scratch = os.path.join(directory, f".zipctl-{secrets.token_hex(8)}.tmp")
45
+ try:
46
+ # 0o666 here: the kernel applies the umask, without touching ours
47
+ handle = os.open(scratch, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666)
48
+ except OSError as exc:
49
+ raise CliError(
50
+ f"cannot create {printable(path)}: {os_error_text(exc)}"
51
+ ) from None
52
+ os.close(handle)
53
+ try:
54
+ if seed and exists:
55
+ shutil.copyfile(target, scratch)
56
+ if exists:
57
+ shutil.copymode(target, scratch) # a 0600 archive stays 0600
58
+ yield scratch
59
+ with open(scratch, "rb+") as written:
60
+ os.fsync(written.fileno())
61
+ # ponytail: check-then-replace, so a file created in this instant is
62
+ # replaced; os.link would be exclusive but is not portable.
63
+ if not overwrite and os.path.lexists(target):
64
+ raise CliError(f"{printable(path)} already exists")
65
+ try:
66
+ os.replace(scratch, target)
67
+ except OSError as exc:
68
+ raise CliError(
69
+ f"cannot replace {printable(path)}: {os_error_text(exc)}"
70
+ ) from None
71
+ _sync_directory(directory)
72
+ except BaseException:
73
+ try:
74
+ os.unlink(scratch)
75
+ except FileNotFoundError:
76
+ pass
77
+ raise
@@ -0,0 +1,8 @@
1
+ """The ``zipctl`` subcommands, one module each.
2
+
3
+ Every module exposes ``register(subparsers)``, which adds its parser and binds
4
+ the handler, so a command's arguments and behaviour live side by side. What
5
+ only commands share lives in :mod:`zipctl.cli.commands.helpers`; so does what
6
+ a single command needs beyond its handler (``collect`` and ``create_report`` for
7
+ ``create``, ``extract_report`` for ``extract``), to keep one module per command.
8
+ """
@@ -0,0 +1,133 @@
1
+ """The ``check-password`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from zipctl.cli.archive import open_archive
8
+ from zipctl.cli.commands.helpers.command import Subparsers, add_command
9
+ from zipctl.cli.commands.helpers.output_options import OutputArgs, add_output_options
10
+ from zipctl.cli.commands.helpers.passwords.options import (
11
+ PasswordArgs,
12
+ PasswordOptions,
13
+ add_password_options,
14
+ single_password,
15
+ )
16
+ from zipctl.cli.commands.helpers.selection import MEMBER_HELP, select_infos
17
+ from zipctl.cli.context import Context
18
+ from zipctl.cli.errors import EXIT_FAILURE, EXIT_OK, CliError
19
+ from zipctl.cli.output import count, printable, write_json
20
+ from zipctl.exceptions import BadZipFile
21
+ from zipctl.zipfile.file import ZipFile
22
+ from zipctl.zipfile.info import ZipInfo
23
+ from zipctl.zipfile.password import (
24
+ MemberPasswordCheck,
25
+ PasswordCheckResult,
26
+ PasswordStatus,
27
+ )
28
+
29
+ _LINES = {
30
+ PasswordStatus.ACCEPTED: ("OK", ""),
31
+ PasswordStatus.REJECTED: ("FAILED", ": password does not match"),
32
+ PasswordStatus.CORRUPT: ("FAILED", ": password matches but the data is corrupt"),
33
+ PasswordStatus.UNENCRYPTED: ("SKIP", ": not encrypted"),
34
+ }
35
+
36
+
37
+ class CheckPasswordArgs(OutputArgs, PasswordArgs, Protocol):
38
+ archive: str
39
+ members: list[str]
40
+ full: bool
41
+
42
+
43
+ def _summary(encrypted: int, accepted: int, full: bool) -> str:
44
+ if not encrypted:
45
+ return "No encrypted members to check"
46
+ text = f"Checked {count(encrypted, 'encrypted member')}: "
47
+ text += "all OK" if accepted == encrypted else f"{encrypted - accepted} failed"
48
+ if accepted and not full:
49
+ text += " (verifier only; use --full to authenticate the data)"
50
+ return text
51
+
52
+
53
+ def _check(
54
+ zf: ZipFile, infos: list[ZipInfo], args: CheckPasswordArgs, ctx: Context
55
+ ) -> PasswordCheckResult:
56
+ if not any(info.is_encrypted for info in infos):
57
+ # Nothing to unlock, so do not ask for (or insist on) a password.
58
+ plain = PasswordStatus.UNENCRYPTED
59
+ return PasswordCheckResult(
60
+ tuple(MemberPasswordCheck(info.filename, plain) for info in infos)
61
+ )
62
+ password = single_password(PasswordOptions.from_args(args), ctx)
63
+ try:
64
+ return zf.check_password(password, members=infos, full=args.full)
65
+ except (BadZipFile, NotImplementedError) as exc:
66
+ raise CliError(f"cannot check the password: {printable(str(exc))}") from None
67
+
68
+
69
+ def cmd_check_password(args: CheckPasswordArgs, ctx: Context) -> int:
70
+ with open_archive(args.archive) as zf:
71
+ result = _check(zf, select_infos(zf.infolist(), args.members), args, ctx)
72
+
73
+ checks = result.members
74
+ encrypted = sum(check.status is not PasswordStatus.UNENCRYPTED for check in checks)
75
+ accepted = len(result.accepted)
76
+ if args.json:
77
+ write_json(
78
+ ctx.stdout,
79
+ {
80
+ "ok": result.ok,
81
+ "archive": args.archive,
82
+ "full": args.full,
83
+ "encrypted": encrypted,
84
+ "accepted": accepted,
85
+ "rejected": len(result.rejected),
86
+ "corrupt": len(result.corrupt),
87
+ "members": [
88
+ {"name": check.member, "status": check.status.value}
89
+ for check in checks
90
+ ],
91
+ },
92
+ )
93
+ else:
94
+ listed = False
95
+ for check in checks:
96
+ label, detail = _LINES[check.status]
97
+ if check.status in (PasswordStatus.REJECTED, PasswordStatus.CORRUPT) or (
98
+ args.verbose
99
+ ):
100
+ ctx.out(f"{label:<8}{printable(check.member)}{detail}")
101
+ listed = True
102
+ if listed:
103
+ ctx.out()
104
+ ctx.out(_summary(encrypted, accepted, args.full))
105
+ return EXIT_OK if result.ok else EXIT_FAILURE
106
+
107
+
108
+ def register(subparsers: Subparsers) -> None:
109
+ parser = add_command(
110
+ subparsers,
111
+ "check-password",
112
+ cmd_check_password,
113
+ "check whether one password matches the encrypted members",
114
+ )
115
+ parser.add_argument(
116
+ "archive",
117
+ metavar="ARCHIVE",
118
+ help="the encrypted archive to check the password against",
119
+ )
120
+ parser.add_argument(
121
+ "members",
122
+ nargs="*",
123
+ metavar="MEMBER",
124
+ help=MEMBER_HELP,
125
+ )
126
+ parser.add_argument(
127
+ "--full",
128
+ action="store_true",
129
+ help="also authenticate each member's data (definitive, but reads the "
130
+ "members). Default: check only the password verifier",
131
+ )
132
+ add_output_options(parser, verbose_help="also list members that match")
133
+ add_password_options(parser)