keepassxc-cli 1.2.0__tar.gz → 1.4.0__tar.gz

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 (38) hide show
  1. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/CLAUDE.md +12 -2
  2. {keepassxc_cli-1.2.0/keepassxc_cli.egg-info → keepassxc_cli-1.4.0}/PKG-INFO +49 -2
  3. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/README.md +47 -0
  4. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__main__.py +25 -4
  5. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/add.py +6 -8
  6. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/clip.py +7 -5
  7. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/edit.py +9 -12
  8. keepassxc_cli-1.4.0/keepassxc_cli/commands/group_uuid.py +56 -0
  9. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/lock.py +4 -2
  10. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/mkdir.py +3 -4
  11. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/rm.py +6 -8
  12. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/setup.py +7 -9
  13. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/show.py +4 -2
  14. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/status.py +9 -5
  15. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/totp.py +5 -3
  16. keepassxc_cli-1.4.0/keepassxc_cli/commands/version.py +31 -0
  17. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0/keepassxc_cli.egg-info}/PKG-INFO +49 -2
  18. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/SOURCES.txt +2 -0
  19. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/requires.txt +1 -1
  20. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/pyproject.toml +1 -1
  21. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/conftest.py +1 -0
  22. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/test_commands.py +155 -26
  23. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-merge-dependabot.yml +0 -0
  24. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/auto-release.yml +0 -0
  25. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/lint_and_test.yml +0 -0
  26. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.github/workflows/pypi.yml +0 -0
  27. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/.gitignore +0 -0
  28. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/LICENSE +0 -0
  29. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/__init__.py +0 -0
  30. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/commands/__init__.py +0 -0
  31. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/config.py +0 -0
  32. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli/output.py +0 -0
  33. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/dependency_links.txt +0 -0
  34. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/entry_points.txt +0 -0
  35. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/keepassxc_cli.egg-info/top_level.txt +0 -0
  36. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/setup.cfg +0 -0
  37. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/test_config.py +0 -0
  38. {keepassxc_cli-1.2.0 → keepassxc_cli-1.4.0}/tests/test_output.py +0 -0
@@ -95,8 +95,18 @@ ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli
95
95
  - **`from __future__ import annotations`** must be the first line of every `.py` source file.
96
96
  - **Ruff**: `ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_cli`
97
97
  - **No async code**: Everything is synchronous. No threads in the CLI.
98
- - **Output**: Use `print()` for normal output, `sys.stderr` for errors.
99
- - **Exit codes**: `run()` functions return `int` (0 = success, 1 = failure).
98
+ - **Output**: Use `print()` for normal (stdout) output. Error/warning messages use `logger.error()` / `logger.warning()` — never `print(file=sys.stderr)` directly.
99
+ - **Logging config**: Set by `__main__.py`. Non-verbose: `WARNING` level, `"%(message)s"` format, to `sys.stderr`. Verbose (`-v`): `DEBUG` level with timestamp format to `sys.stderr`.
100
+ - **Exit codes**: `run()` functions return `int` (0 = success, non-zero = failure). `__main__.py` maps exceptions to exit codes:
101
+
102
+ | Code | Meaning |
103
+ |---|---|
104
+ | `0` | Success |
105
+ | `1` | Generic error (`KeePassXCError`, `OSError`, `JSONDecodeError`, unknown `ProtocolError`) |
106
+ | `2` | `ConnectionError` — KeePassXC not running / socket not found |
107
+ | `3` | `DatabaseLockedError` — unlock timeout exceeded |
108
+ | `4` | `ProtocolError(error_code=6 or 19)` — access denied by user |
109
+
100
110
  - **Config permissions**: Config files are written with `0o600` (owner read/write only).
101
111
  - **Venv**: Always use `.venv` for development.
102
112
  - **Python ≥ 3.10** required.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: keepassxc-browser-api==1.1.0
9
+ Requires-Dist: keepassxc-browser-api==1.3.0
10
10
  Requires-Dist: pyperclip==1.8.0
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -163,6 +163,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
163
163
 
164
164
  Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
165
165
 
166
+ #### `group-uuid` — Look up a group's UUID by path
167
+
168
+ ```bash
169
+ keepassxc-cli group-uuid "Work"
170
+ keepassxc-cli group-uuid "Work/Projects"
171
+ keepassxc-cli group-uuid "Work/Projects" -j
172
+ ```
173
+
174
+ Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
175
+
176
+ JSON output (`-j`):
177
+ ```json
178
+ {
179
+ "path": "Work/Projects",
180
+ "name": "Projects",
181
+ "uuid": "<uuid>"
182
+ }
183
+ ```
184
+
185
+ #### `version` — Show the CLI version
186
+
187
+ ```bash
188
+ keepassxc-cli version
189
+ ```
190
+
191
+ Does not require a running KeePassXC instance.
192
+
166
193
  ## Configuration
167
194
 
168
195
  ### CLI config (`~/.keepassxc/cli.json`)
@@ -187,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
187
214
 
188
215
  Both config files are stored with `0o600` permissions (owner read/write only).
189
216
 
217
+ ## Exit codes
218
+
219
+ | Code | Meaning |
220
+ |------|---------|
221
+ | `0` | Success |
222
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
223
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
224
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
225
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
226
+
227
+ These codes are stable and suitable for scripting, e.g.:
228
+
229
+ ```bash
230
+ keepassxc-cli show https://example.com || case $? in
231
+ 2) echo "Start KeePassXC first" ;;
232
+ 3) echo "Unlock timed out" ;;
233
+ 4) echo "Access denied" ;;
234
+ esac
235
+ ```
236
+
190
237
  ## Development
191
238
 
192
239
  ```bash
@@ -148,6 +148,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
148
148
 
149
149
  Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
150
150
 
151
+ #### `group-uuid` — Look up a group's UUID by path
152
+
153
+ ```bash
154
+ keepassxc-cli group-uuid "Work"
155
+ keepassxc-cli group-uuid "Work/Projects"
156
+ keepassxc-cli group-uuid "Work/Projects" -j
157
+ ```
158
+
159
+ Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
160
+
161
+ JSON output (`-j`):
162
+ ```json
163
+ {
164
+ "path": "Work/Projects",
165
+ "name": "Projects",
166
+ "uuid": "<uuid>"
167
+ }
168
+ ```
169
+
170
+ #### `version` — Show the CLI version
171
+
172
+ ```bash
173
+ keepassxc-cli version
174
+ ```
175
+
176
+ Does not require a running KeePassXC instance.
177
+
151
178
  ## Configuration
152
179
 
153
180
  ### CLI config (`~/.keepassxc/cli.json`)
@@ -172,6 +199,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
172
199
 
173
200
  Both config files are stored with `0o600` permissions (owner read/write only).
174
201
 
202
+ ## Exit codes
203
+
204
+ | Code | Meaning |
205
+ |------|---------|
206
+ | `0` | Success |
207
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
208
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
209
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
210
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
211
+
212
+ These codes are stable and suitable for scripting, e.g.:
213
+
214
+ ```bash
215
+ keepassxc-cli show https://example.com || case $? in
216
+ 2) echo "Start KeePassXC first" ;;
217
+ 3) echo "Unlock timed out" ;;
218
+ 4) echo "Access denied" ;;
219
+ esac
220
+ ```
221
+
175
222
  ## Development
176
223
 
177
224
  ```bash
@@ -8,10 +8,10 @@ import sys
8
8
  from pathlib import Path
9
9
 
10
10
  from keepassxc_browser_api import BrowserClient, BrowserConfig
11
- from keepassxc_browser_api.exceptions import KeePassXCError, ConnectionError
11
+ from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, KeePassXCError, ProtocolError
12
12
 
13
13
  from .config import CliConfig, DEFAULT_CLI_CONFIG_PATH
14
- from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir
14
+ from .commands import setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version
15
15
 
16
16
  # Shared parent parser that injects -j/--json into each subparser that supports it.
17
17
  # Defined at module level so command modules can import it if needed.
@@ -55,11 +55,23 @@ def main() -> None:
55
55
  clip.add_parser(subparsers)
56
56
  lock.add_parser(subparsers)
57
57
  mkdir.add_parser(subparsers)
58
+ group_uuid.add_parser(subparsers, fmt_parent)
59
+ version.add_parser(subparsers)
58
60
 
59
61
  args = parser.parse_args()
60
62
 
61
63
  if args.verbose:
62
- logging.basicConfig(level=logging.DEBUG)
64
+ logging.basicConfig(
65
+ level=logging.DEBUG,
66
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
67
+ datefmt="%H:%M:%S",
68
+ )
69
+ else:
70
+ logging.basicConfig(
71
+ level=logging.WARNING,
72
+ format="%(message)s",
73
+ stream=sys.stderr,
74
+ )
63
75
 
64
76
  cli_config_path = Path(args.config)
65
77
  cli_config = CliConfig.load(cli_config_path)
@@ -72,7 +84,16 @@ def main() -> None:
72
84
  client = BrowserClient(browser_config)
73
85
  try:
74
86
  rc = args.func(client, args, cli_config, browser_config, browser_api_config_path, fmt=fmt)
75
- except (KeePassXCError, ConnectionError, OSError, json.JSONDecodeError) as e:
87
+ except ConnectionError as e:
88
+ print(f"Error: {e}", file=sys.stderr)
89
+ rc = 2
90
+ except DatabaseLockedError as e:
91
+ print(f"Error: {e}", file=sys.stderr)
92
+ rc = 3
93
+ except ProtocolError as e:
94
+ print(f"Error: {e}", file=sys.stderr)
95
+ rc = 4 if e.error_code in (6, 19) else 1
96
+ except (KeePassXCError, OSError, json.JSONDecodeError) as e:
76
97
  print(f"Error: {e}", file=sys.stderr)
77
98
  rc = 1
78
99
  sys.exit(rc)
@@ -2,13 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import getpass
5
- import sys
5
+ import logging
6
6
  from pathlib import Path
7
7
 
8
8
  from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
 
10
10
  from keepassxc_cli.config import CliConfig
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
14
16
  p = subparsers.add_parser("add", help="Add a new entry")
@@ -32,15 +34,11 @@ def run(
32
34
  if password is None:
33
35
  password = getpass.getpass("Password: ")
34
36
 
35
- success = client.set_login(
37
+ client.set_login(
36
38
  url=args.url,
37
39
  username=args.username,
38
40
  password=password,
39
41
  group_uuid=args.group_uuid,
40
42
  )
41
- if success:
42
- print("Entry added successfully.")
43
- return 0
44
- else:
45
- print("Failed to add entry.", file=sys.stderr)
46
- return 1
43
+ print("Entry added successfully.")
44
+ return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("clip", help="Copy a field to clipboard")
@@ -32,12 +34,12 @@ def run(
32
34
  try:
33
35
  import pyperclip
34
36
  except ImportError:
35
- print("Error: pyperclip is required for clipboard support. Install it with: pip install pyperclip", file=sys.stderr)
37
+ logger.error("pyperclip is required for clipboard support. Install it with: pip install pyperclip")
36
38
  return 1
37
39
 
38
40
  entries = client.get_logins(args.url)
39
41
  if not entries:
40
- print(f"No entries found for: {args.url}", file=sys.stderr)
42
+ logger.warning("No entries found for: %s", args.url)
41
43
  return 1
42
44
 
43
45
  entry = entries[0]
@@ -49,10 +51,10 @@ def run(
49
51
  elif args.field == "totp":
50
52
  value = client.get_totp(entry.uuid)
51
53
  if value is None:
52
- print(f"No TOTP configured for: {entry.name}", file=sys.stderr)
54
+ logger.warning("No TOTP configured for: %s", entry.name)
53
55
  return 1
54
56
  else:
55
- print(f"Unknown field: {args.field}", file=sys.stderr)
57
+ logger.error("Unknown field: %s", args.field)
56
58
  return 1
57
59
 
58
60
  pyperclip.copy(value)
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser(
@@ -39,14 +41,13 @@ def run(
39
41
  entries = client.get_logins(args.url)
40
42
  entry = next((e for e in entries if e.uuid == args.uuid), None)
41
43
  if entry is None:
42
- print(
43
- f"Entry {args.uuid} not found for URL: {args.url}\n"
44
- "Hint: use 'keepassxc-cli show <url>' to look up the UUID.",
45
- file=sys.stderr,
44
+ logger.error(
45
+ "Entry %s not found for URL: %s\nHint: use 'keepassxc-cli show <url>' to look up the UUID.",
46
+ args.uuid, args.url,
46
47
  )
47
48
  return 1
48
49
 
49
- success = client.set_login(
50
+ client.set_login(
50
51
  url=args.url,
51
52
  username=args.username if args.username is not None else entry.login,
52
53
  password=args.password if args.password is not None else entry.password,
@@ -54,9 +55,5 @@ def run(
54
55
  uuid=entry.uuid,
55
56
  group_uuid=entry.group_uuid,
56
57
  )
57
- if success:
58
- print("Entry updated successfully.")
59
- return 0
60
- else:
61
- print("Failed to update entry.", file=sys.stderr)
62
- return 1
58
+ print("Entry updated successfully.")
59
+ return 0
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
+
10
+ from keepassxc_cli.config import CliConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
16
+ parents = [fmt_parent] if fmt_parent else []
17
+ p = subparsers.add_parser(
18
+ "group-uuid",
19
+ parents=parents,
20
+ help="Look up the UUID of a group by its path",
21
+ )
22
+ p.add_argument(
23
+ "path",
24
+ help="Group path relative to the database root (e.g. 'Work/Projects')",
25
+ )
26
+ p.set_defaults(func=run)
27
+
28
+
29
+ def run(
30
+ client: BrowserClient,
31
+ args: argparse.Namespace,
32
+ cli_config: CliConfig,
33
+ browser_config: BrowserConfig,
34
+ browser_config_path: Path,
35
+ *,
36
+ fmt: str = "table",
37
+ ) -> int:
38
+ groups = client.get_database_groups()
39
+ root = groups[0]
40
+ parts = args.path.split("/")
41
+
42
+ # Paths are root-relative: traverse root.children, not the root itself.
43
+ current = root.children
44
+ matched = None
45
+ for part in parts:
46
+ matched = next((g for g in current if g.name == part), None)
47
+ if matched is None:
48
+ logger.error("Group not found: %r", args.path)
49
+ return 1
50
+ current = matched.children
51
+
52
+ if fmt == "json":
53
+ print(json.dumps({"path": args.path, "name": matched.name, "uuid": matched.uuid}, indent=2))
54
+ else:
55
+ print(f"{args.path} [{matched.uuid}]")
56
+ return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("lock", help="Lock the KeePassXC database")
@@ -28,5 +30,5 @@ def run(
28
30
  print("Database locked.")
29
31
  return 0
30
32
  else:
31
- print("Failed to lock database.", file=sys.stderr)
33
+ logger.error("Failed to lock database.")
32
34
  return 1
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("mkdir", help="Create a new group")
@@ -28,8 +30,5 @@ def run(
28
30
  fmt: str = "table",
29
31
  ) -> int:
30
32
  group = client.create_group(args.name)
31
- if group is None:
32
- print("Failed to create group.", file=sys.stderr)
33
- return 1
34
33
  print(f"Group created: {group.name} [{group.uuid}]")
35
34
  return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("rm", help="Delete an entry by UUID")
@@ -31,10 +33,6 @@ def run(
31
33
  print("Aborted.")
32
34
  return 1
33
35
 
34
- success = client.delete_entry(args.uuid)
35
- if success:
36
- print("Entry deleted.")
37
- return 0
38
- else:
39
- print("Failed to delete entry.", file=sys.stderr)
40
- return 1
36
+ client.delete_entry(args.uuid)
37
+ print("Entry deleted.")
38
+ return 0
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
8
8
 
9
9
  from keepassxc_cli.config import CliConfig
10
10
 
11
+ logger = logging.getLogger(__name__)
12
+
11
13
 
12
14
  def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
15
  p = subparsers.add_parser("setup", help="Associate with KeePassXC (key exchange)")
@@ -23,11 +25,7 @@ def run(
23
25
  *,
24
26
  fmt: str = "table",
25
27
  ) -> int:
26
- success = client.setup()
27
- if success:
28
- browser_config.save(browser_config_path)
29
- print("Successfully associated with KeePassXC.")
30
- return 0
31
- else:
32
- print("Failed to associate with KeePassXC.", file=sys.stderr)
33
- return 1
28
+ client.setup()
29
+ browser_config.save(browser_config_path)
30
+ print("Successfully associated with KeePassXC.")
31
+ return 0
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
@@ -9,6 +9,8 @@ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
  from keepassxc_cli.config import CliConfig
10
10
  from keepassxc_cli.output import print_entry_detail
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
16
  parents = [fmt_parent] if fmt_parent else []
@@ -30,7 +32,7 @@ def run(
30
32
  ) -> int:
31
33
  entries = client.get_logins(args.url)
32
34
  if not entries:
33
- print(f"No entries found for: {args.url}", file=sys.stderr)
35
+ logger.warning("No entries found for: %s", args.url)
34
36
  return 1
35
37
  for entry in entries:
36
38
  print_entry_detail(entry, fmt, show_password=args.show_password, show_kph_prefix=getattr(args, "show_kph_prefix", False))
@@ -1,14 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import logging
4
5
  from pathlib import Path
5
6
 
6
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
7
- from keepassxc_browser_api.exceptions import KeePassXCError
8
+ from keepassxc_browser_api.exceptions import ConnectionError, KeePassXCError
8
9
 
9
10
  from keepassxc_cli.config import CliConfig
10
11
  from keepassxc_cli.output import print_status
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
 
13
16
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
17
  parents = [fmt_parent] if fmt_parent else []
@@ -27,10 +30,11 @@ def run(
27
30
  ) -> int:
28
31
  info: dict = {}
29
32
 
30
- connected = client.connect()
31
- info["Connected"] = "yes" if connected else "no"
32
-
33
- if not connected:
33
+ try:
34
+ client.connect()
35
+ info["Connected"] = "yes"
36
+ except ConnectionError:
37
+ info["Connected"] = "no"
34
38
  print_status(info, fmt)
35
39
  return 1
36
40
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
- import sys
4
+ import logging
5
5
  from pathlib import Path
6
6
 
7
7
  from keepassxc_browser_api import BrowserClient, BrowserConfig
@@ -9,6 +9,8 @@ from keepassxc_browser_api import BrowserClient, BrowserConfig
9
9
  from keepassxc_cli.config import CliConfig
10
10
  from keepassxc_cli.output import print_totp
11
11
 
12
+ logger = logging.getLogger(__name__)
13
+
12
14
 
13
15
  def add_parser(subparsers: argparse._SubParsersAction, fmt_parent: argparse.ArgumentParser | None = None) -> None:
14
16
  parents = [fmt_parent] if fmt_parent else []
@@ -28,12 +30,12 @@ def run(
28
30
  ) -> int:
29
31
  entries = client.get_logins(args.url)
30
32
  if not entries:
31
- print(f"No entries found for: {args.url}", file=sys.stderr)
33
+ logger.warning("No entries found for: %s", args.url)
32
34
  return 1
33
35
  entry = entries[0]
34
36
  totp = client.get_totp(entry.uuid)
35
37
  if totp is None:
36
- print(f"No TOTP configured for: {entry.name}", file=sys.stderr)
38
+ logger.warning("No TOTP configured for: %s", entry.name)
37
39
  return 1
38
40
  print_totp(totp, fmt)
39
41
  return 0
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from pathlib import Path
6
+
7
+ from keepassxc_browser_api import BrowserClient, BrowserConfig
8
+
9
+ from keepassxc_cli.config import CliConfig
10
+
11
+
12
+ def add_parser(subparsers: argparse._SubParsersAction) -> None:
13
+ p = subparsers.add_parser("version", help="Show the keepassxc-cli version")
14
+ p.set_defaults(func=run)
15
+
16
+
17
+ def run(
18
+ client: BrowserClient,
19
+ args: argparse.Namespace,
20
+ cli_config: CliConfig,
21
+ browser_config: BrowserConfig,
22
+ browser_config_path: Path,
23
+ *,
24
+ fmt: str = "table",
25
+ ) -> int:
26
+ try:
27
+ ver = version("keepassxc-cli")
28
+ except PackageNotFoundError:
29
+ ver = "unknown"
30
+ print(f"keepassxc-cli {ver}")
31
+ return 0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepassxc-cli
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: CLI for KeePassXC using the browser extension protocol with biometric unlock
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: keepassxc-browser-api==1.1.0
9
+ Requires-Dist: keepassxc-browser-api==1.3.0
10
10
  Requires-Dist: pyperclip==1.8.0
11
11
  Provides-Extra: dev
12
12
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -163,6 +163,33 @@ keepassxc-cli mkdir "Work/Projects" # create Projects inside Work
163
163
 
164
164
  Use `/`-separated paths to create nested groups. KeePassXC creates any missing path segments automatically.
165
165
 
166
+ #### `group-uuid` — Look up a group's UUID by path
167
+
168
+ ```bash
169
+ keepassxc-cli group-uuid "Work"
170
+ keepassxc-cli group-uuid "Work/Projects"
171
+ keepassxc-cli group-uuid "Work/Projects" -j
172
+ ```
173
+
174
+ Returns the UUID for the group at the given path (relative to the database root). Useful for scripting — pipe the UUID into `add --group-uuid`.
175
+
176
+ JSON output (`-j`):
177
+ ```json
178
+ {
179
+ "path": "Work/Projects",
180
+ "name": "Projects",
181
+ "uuid": "<uuid>"
182
+ }
183
+ ```
184
+
185
+ #### `version` — Show the CLI version
186
+
187
+ ```bash
188
+ keepassxc-cli version
189
+ ```
190
+
191
+ Does not require a running KeePassXC instance.
192
+
166
193
  ## Configuration
167
194
 
168
195
  ### CLI config (`~/.keepassxc/cli.json`)
@@ -187,6 +214,26 @@ Shared with `keepassxc-browser-api`. Contains the association keys created durin
187
214
 
188
215
  Both config files are stored with `0o600` permissions (owner read/write only).
189
216
 
217
+ ## Exit codes
218
+
219
+ | Code | Meaning |
220
+ |------|---------|
221
+ | `0` | Success |
222
+ | `1` | Generic error (unexpected KeePassXC error, OS error, config parse error) |
223
+ | `2` | KeePassXC is not running or the socket is not found (`ConnectionError`) |
224
+ | `3` | Database unlock timed out (`DatabaseLockedError`) |
225
+ | `4` | Access denied by user — either the access prompt was cancelled or "Allow access to all entries" was denied |
226
+
227
+ These codes are stable and suitable for scripting, e.g.:
228
+
229
+ ```bash
230
+ keepassxc-cli show https://example.com || case $? in
231
+ 2) echo "Start KeePassXC first" ;;
232
+ 3) echo "Unlock timed out" ;;
233
+ 4) echo "Access denied" ;;
234
+ esac
235
+ ```
236
+
190
237
  ## Development
191
238
 
192
239
  ```bash
@@ -21,6 +21,7 @@ keepassxc_cli/commands/__init__.py
21
21
  keepassxc_cli/commands/add.py
22
22
  keepassxc_cli/commands/clip.py
23
23
  keepassxc_cli/commands/edit.py
24
+ keepassxc_cli/commands/group_uuid.py
24
25
  keepassxc_cli/commands/lock.py
25
26
  keepassxc_cli/commands/mkdir.py
26
27
  keepassxc_cli/commands/rm.py
@@ -28,6 +29,7 @@ keepassxc_cli/commands/setup.py
28
29
  keepassxc_cli/commands/show.py
29
30
  keepassxc_cli/commands/status.py
30
31
  keepassxc_cli/commands/totp.py
32
+ keepassxc_cli/commands/version.py
31
33
  tests/conftest.py
32
34
  tests/test_commands.py
33
35
  tests/test_config.py
@@ -1,4 +1,4 @@
1
- keepassxc-browser-api==1.1.0
1
+ keepassxc-browser-api==1.3.0
2
2
  pyperclip==1.8.0
3
3
 
4
4
  [dev]
@@ -10,7 +10,7 @@ readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  dependencies = [
13
- "keepassxc-browser-api==1.1.0",
13
+ "keepassxc-browser-api==1.3.0",
14
14
  "pyperclip==1.8.0",
15
15
  ]
16
16
 
@@ -71,6 +71,7 @@ def mock_client():
71
71
  client.get_logins.return_value = [make_entry()]
72
72
  client.set_login.return_value = True
73
73
  client.create_group.return_value = make_group(uuid="new-uuid", name="NewGroup")
74
+ client.get_database_groups.return_value = [make_group()]
74
75
  client.get_totp.return_value = "123456"
75
76
  client.delete_entry.return_value = True
76
77
  client.lock_database.return_value = True
@@ -8,9 +8,10 @@ from unittest.mock import MagicMock, patch
8
8
  import pytest
9
9
 
10
10
  from keepassxc_browser_api import Entry, BrowserConfig, Association
11
+ from keepassxc_browser_api.exceptions import ConnectionError, DatabaseLockedError, ProtocolError
11
12
  from keepassxc_cli.config import CliConfig
12
13
  from keepassxc_cli.commands import (
13
- setup, status, show, add, edit, rm, totp, clip, lock, mkdir,
14
+ setup, status, show, add, edit, rm, totp, clip, lock, mkdir, group_uuid, version,
14
15
  )
15
16
 
16
17
 
@@ -41,6 +42,7 @@ def make_args(**kwargs) -> argparse.Namespace:
41
42
  "group_uuid": "",
42
43
  "uuid": "abcdef12-0000-0000-0000-000000000000",
43
44
  "name": "NewGroup",
45
+ "path": "Work",
44
46
  }
45
47
  defaults.update(kwargs)
46
48
  return argparse.Namespace(**defaults)
@@ -49,19 +51,18 @@ def make_args(**kwargs) -> argparse.Namespace:
49
51
  # --- setup ---
50
52
 
51
53
  class TestSetupCommand:
52
- def test_success(self, mock_client, cli_config, browser_config, browser_config_path):
53
- mock_client.setup.return_value = True
54
+ def test_success(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
55
+ mock_client.setup.return_value = None
54
56
  args = make_args()
55
57
  rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
56
58
  assert rc == 0
57
59
  mock_client.setup.assert_called_once()
58
60
 
59
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
60
- mock_client.setup.return_value = False
61
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
62
+ mock_client.setup.side_effect = ConnectionError("KeePassXC not running")
61
63
  args = make_args()
62
- rc = setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
63
- assert rc == 1
64
- assert "Failed" in capsys.readouterr().err
64
+ with pytest.raises(ConnectionError):
65
+ setup.run(mock_client, args, cli_config, browser_config, browser_config_path)
65
66
 
66
67
 
67
68
  # --- status ---
@@ -77,7 +78,7 @@ class TestStatusCommand:
77
78
  assert "yes" in out
78
79
 
79
80
  def test_not_connected(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
80
- mock_client.connect.return_value = False
81
+ mock_client.connect.side_effect = ConnectionError("not available")
81
82
  args = make_args()
82
83
  rc = status.run(mock_client, args, cli_config, browser_config, browser_config_path)
83
84
  assert rc == 1
@@ -127,12 +128,12 @@ class TestShowCommand:
127
128
  assert rc == 0
128
129
  assert "KPH: url: https://github.com" in capsys.readouterr().out
129
130
 
130
- def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
131
+ def test_no_entries(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
131
132
  mock_client.get_logins.return_value = []
132
133
  args = make_args(url="https://notfound.com")
133
134
  rc = show.run(mock_client, args, cli_config, browser_config, browser_config_path)
134
135
  assert rc == 1
135
- assert "No entries" in capsys.readouterr().err
136
+ assert any("No entries" in r.message for r in caplog.records)
136
137
 
137
138
 
138
139
  # --- add ---
@@ -146,11 +147,11 @@ class TestAddCommand:
146
147
  mock_client.set_login.assert_called_once()
147
148
  assert "added" in capsys.readouterr().out.lower()
148
149
 
149
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
150
- mock_client.set_login.return_value = False
150
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
151
+ mock_client.set_login.side_effect = ProtocolError("access denied", error_code=6)
151
152
  args = make_args(url="https://example.com", username="u", password="p", group_uuid="")
152
- rc = add.run(mock_client, args, cli_config, browser_config, browser_config_path)
153
- assert rc == 1
153
+ with pytest.raises(ProtocolError):
154
+ add.run(mock_client, args, cli_config, browser_config, browser_config_path)
154
155
 
155
156
 
156
157
  # --- edit ---
@@ -165,12 +166,12 @@ class TestEditCommand:
165
166
  assert rc == 0
166
167
  assert "updated" in capsys.readouterr().out.lower()
167
168
 
168
- def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
169
+ def test_entry_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
169
170
  mock_client.get_logins.return_value = []
170
171
  args = make_args(uuid="nonexistent-uuid", url="https://example.com", username=None, password=None, title=None)
171
172
  rc = edit.run(mock_client, args, cli_config, browser_config, browser_config_path)
172
173
  assert rc == 1
173
- assert "not found" in capsys.readouterr().err.lower()
174
+ assert any("not found" in r.message.lower() for r in caplog.records)
174
175
 
175
176
 
176
177
  # --- rm ---
@@ -184,11 +185,11 @@ class TestRmCommand:
184
185
  mock_client.delete_entry.assert_called_once_with("some-uuid")
185
186
  assert "deleted" in capsys.readouterr().out.lower()
186
187
 
187
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
188
- mock_client.delete_entry.return_value = False
188
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
189
+ mock_client.delete_entry.side_effect = ProtocolError("access denied", error_code=6)
189
190
  args = make_args(uuid="some-uuid", yes=True)
190
- rc = rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
191
- assert rc == 1
191
+ with pytest.raises(ProtocolError):
192
+ rm.run(mock_client, args, cli_config, browser_config, browser_config_path)
192
193
 
193
194
 
194
195
  # --- totp ---
@@ -237,12 +238,12 @@ class TestClipCommand:
237
238
  rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
238
239
  assert rc == 1
239
240
 
240
- def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
241
+ def test_pyperclip_missing(self, mock_client, cli_config, browser_config, browser_config_path, caplog):
241
242
  args = make_args(url="https://example.com", field="password")
242
243
  with patch.dict("sys.modules", {"pyperclip": None}):
243
244
  rc = clip.run(mock_client, args, cli_config, browser_config, browser_config_path)
244
245
  assert rc == 1
245
- assert "pyperclip" in capsys.readouterr().err
246
+ assert any("pyperclip" in r.message for r in caplog.records)
246
247
 
247
248
 
248
249
  # --- lock ---
@@ -282,8 +283,136 @@ class TestMkdirCommand:
282
283
  assert rc == 0
283
284
  mock_client.create_group.assert_called_once_with("Work/Projects")
284
285
 
285
- def test_failure(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
286
- mock_client.create_group.return_value = None
286
+ def test_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path):
287
+ mock_client.create_group.side_effect = ConnectionError("KeePassXC not running")
287
288
  args = make_args(name="MyGroup")
288
- rc = mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
289
+ with pytest.raises(ConnectionError):
290
+ mkdir.run(mock_client, args, cli_config, browser_config, browser_config_path)
291
+
292
+
293
+ # --- group-uuid ---
294
+
295
+
296
+ class TestGroupUuidCommand:
297
+ def _make_tree(self, mock_group):
298
+ from keepassxc_browser_api import Group
299
+ projects = mock_group(uuid="projects-uuid", name="Projects")
300
+ work = mock_group(uuid="work-uuid", name="Work", children=[projects])
301
+ root = mock_group(uuid="root-uuid", name="Root", children=[work])
302
+ return [root]
303
+
304
+ def test_found_top_level(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
305
+ mock_client.get_database_groups.return_value = self._make_tree(mock_group)
306
+ args = make_args(path="Work")
307
+ rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
308
+ assert rc == 0
309
+ out = capsys.readouterr().out
310
+ assert "work-uuid" in out
311
+ assert "Work" in out
312
+
313
+ def test_found_nested_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
314
+ mock_client.get_database_groups.return_value = self._make_tree(mock_group)
315
+ args = make_args(path="Work/Projects")
316
+ rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
317
+ assert rc == 0
318
+ out = capsys.readouterr().out
319
+ assert "projects-uuid" in out
320
+
321
+ def test_not_found(self, mock_client, cli_config, browser_config, browser_config_path, caplog, mock_group):
322
+ mock_client.get_database_groups.return_value = self._make_tree(mock_group)
323
+ args = make_args(path="Nonexistent")
324
+ rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
325
+ assert rc == 1
326
+ assert any("not found" in r.message.lower() for r in caplog.records)
327
+
328
+ def test_not_found_mid_path(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
329
+ mock_client.get_database_groups.return_value = self._make_tree(mock_group)
330
+ args = make_args(path="Work/Nope/Projects")
331
+ rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
332
+ assert rc == 1
333
+
334
+ def test_json_output(self, mock_client, cli_config, browser_config, browser_config_path, capsys, mock_group):
335
+ import json
336
+ mock_client.get_database_groups.return_value = self._make_tree(mock_group)
337
+ args = make_args(path="Work/Projects")
338
+ rc = group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path, fmt="json")
339
+ assert rc == 0
340
+ data = json.loads(capsys.readouterr().out)
341
+ assert data["path"] == "Work/Projects"
342
+ assert data["name"] == "Projects"
343
+ assert data["uuid"] == "projects-uuid"
344
+
345
+ def test_get_database_groups_failure_propagates(self, mock_client, cli_config, browser_config, browser_config_path, mock_group):
346
+ mock_client.get_database_groups.side_effect = ConnectionError("KeePassXC not running")
347
+ args = make_args(path="Work")
348
+ with pytest.raises(ConnectionError):
349
+ group_uuid.run(mock_client, args, cli_config, browser_config, browser_config_path)
350
+
351
+ # --- version ---
352
+
353
+
354
+ class TestVersionCommand:
355
+ def test_prints_version(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
356
+ with patch("keepassxc_cli.commands.version.version", return_value="1.3.0"):
357
+ args = make_args()
358
+ rc = version.run(mock_client, args, cli_config, browser_config, browser_config_path)
359
+ assert rc == 0
360
+ out = capsys.readouterr().out
361
+ assert "keepassxc-cli" in out
362
+ assert "1.3.0" in out
363
+
364
+ def test_package_not_found(self, mock_client, cli_config, browser_config, browser_config_path, capsys):
365
+ from importlib.metadata import PackageNotFoundError
366
+ with patch("keepassxc_cli.commands.version.version", side_effect=PackageNotFoundError):
367
+ args = make_args()
368
+ rc = version.run(mock_client, args, cli_config, browser_config, browser_config_path)
369
+ assert rc == 0
370
+ out = capsys.readouterr().out
371
+ assert "unknown" in out
372
+
373
+
374
+ # --- exit codes ---
375
+
376
+ class TestExitCodes:
377
+ """Test that __main__.main() maps exceptions to the correct exit codes."""
378
+
379
+ def _run_main(self, exception, monkeypatch, capsys, tmp_path):
380
+ from keepassxc_cli.__main__ import main
381
+
382
+ config_path = tmp_path / "cli.json"
383
+ monkeypatch.setattr("sys.argv", [
384
+ "keepassxc-cli", "--config", str(config_path),
385
+ "show", "https://example.com",
386
+ ])
387
+
388
+ mock_client_instance = MagicMock()
389
+ mock_client_instance.get_logins.side_effect = exception
390
+
391
+ with patch("keepassxc_cli.__main__.BrowserClient", return_value=mock_client_instance):
392
+ with patch("keepassxc_cli.__main__.BrowserConfig"):
393
+ with patch("keepassxc_cli.__main__.CliConfig"):
394
+ with pytest.raises(SystemExit) as exc_info:
395
+ main()
396
+
397
+ return exc_info.value.code, capsys.readouterr()
398
+
399
+ def test_connection_error_rc2(self, monkeypatch, capsys, tmp_path):
400
+ rc, out = self._run_main(ConnectionError("not running"), monkeypatch, capsys, tmp_path)
401
+ assert rc == 2
402
+ assert "Error:" in out.err
403
+
404
+ def test_database_locked_rc3(self, monkeypatch, capsys, tmp_path):
405
+ rc, out = self._run_main(DatabaseLockedError("locked"), monkeypatch, capsys, tmp_path)
406
+ assert rc == 3
407
+
408
+ def test_access_denied_rc4(self, monkeypatch, capsys, tmp_path):
409
+ rc, out = self._run_main(ProtocolError("denied", error_code=6), monkeypatch, capsys, tmp_path)
410
+ assert rc == 4
411
+
412
+ def test_access_denied_code19_rc4(self, monkeypatch, capsys, tmp_path):
413
+ rc, out = self._run_main(ProtocolError("denied", error_code=19), monkeypatch, capsys, tmp_path)
414
+ assert rc == 4
415
+
416
+ def test_other_protocol_error_rc1(self, monkeypatch, capsys, tmp_path):
417
+ rc, out = self._run_main(ProtocolError("other error", error_code=7), monkeypatch, capsys, tmp_path)
289
418
  assert rc == 1
File without changes
File without changes
File without changes