jwbmisc 0.0.3__tar.gz → 0.0.5__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 (37) hide show
  1. {jwbmisc-0.0.3/src/jwbmisc.egg-info → jwbmisc-0.0.5}/PKG-INFO +1 -1
  2. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/__init__.py +3 -2
  3. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/_version.py +3 -3
  4. jwbmisc-0.0.5/src/jwbmisc/collection.py +27 -0
  5. jwbmisc-0.0.5/src/jwbmisc/interactive.py +13 -0
  6. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/keeper.py +23 -22
  7. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/passwd.py +5 -3
  8. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/string.py +20 -0
  9. {jwbmisc-0.0.3 → jwbmisc-0.0.5/src/jwbmisc.egg-info}/PKG-INFO +1 -1
  10. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/SOURCES.txt +5 -3
  11. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/conftest.py +0 -6
  12. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/pass +2 -2
  13. jwbmisc-0.0.5/tests/test_collection.py +41 -0
  14. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_exec.py +16 -12
  15. jwbmisc-0.0.5/tests/test_interactive.py +49 -0
  16. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_keeper.py +80 -66
  17. jwbmisc-0.0.5/tests/test_passwd.py +84 -0
  18. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_string.py +49 -1
  19. jwbmisc-0.0.3/src/jwbmisc/util.py +0 -64
  20. jwbmisc-0.0.3/tests/test_passwd.py +0 -121
  21. jwbmisc-0.0.3/tests/test_util.py +0 -138
  22. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/LICENSE +0 -0
  23. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/MANIFEST.in +0 -0
  24. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/Makefile +0 -0
  25. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/README.md +0 -0
  26. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/pyproject.toml +0 -0
  27. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/release.sh +0 -0
  28. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/setup.cfg +0 -0
  29. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/exec.py +0 -0
  30. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/fs.py +0 -0
  31. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/json.py +0 -0
  32. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/dependency_links.txt +0 -0
  33. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/requires.txt +0 -0
  34. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/top_level.txt +0 -0
  35. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/fzf +0 -0
  36. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_fs.py +0 -0
  37. {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_json.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jwbmisc
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Misc util functions of jwb
5
5
  Author-email: Joachim Bargsten <jw@bargsten.org>
6
6
  License: Apache License
@@ -1,9 +1,10 @@
1
1
  from .passwd import get_pass
2
- from .string import jinja_replace
2
+ from .string import jinja_replace, randomsuffix, qw, split_host
3
3
  from .exec import run_cmd
4
4
  from .json import jsonc_loads, jsonc_read, ndjson_read, ndjson_write, resilient_loads
5
5
  from .fs import fzf, find_root
6
- from .util import goo, ask, confirm, randomsuffix, qw, split_host
6
+ from .collection import goo
7
+ from .interactive import ask, confirm
7
8
 
8
9
  __all__ = [
9
10
  "get_pass",
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.3'
32
- __version_tuple__ = version_tuple = (0, 0, 3)
31
+ __version__ = version = '0.0.5'
32
+ __version_tuple__ = version_tuple = (0, 0, 5)
33
33
 
34
- __commit_id__ = commit_id = 'g75f9cb167'
34
+ __commit_id__ = commit_id = 'g68c0a3571'
@@ -0,0 +1,27 @@
1
+ from typing import Any
2
+
3
+
4
+ def goo(
5
+ d: dict[str, Any],
6
+ *keys: str | int,
7
+ default: Any | None = None,
8
+ raise_on_default: bool = False,
9
+ ):
10
+ path = ".".join(str(k) for k in keys)
11
+ parts = path.split(".")
12
+
13
+ res = d
14
+ for p in parts:
15
+ if res is None:
16
+ if raise_on_default:
17
+ raise ValueError(f"'{path}' does not exist")
18
+ return default
19
+ if isinstance(res, (list, set, tuple)):
20
+ res = res[int(p)]
21
+ else:
22
+ res = res.get(p)
23
+ if res is None:
24
+ if raise_on_default:
25
+ raise ValueError(f"'{path}' does not exist")
26
+ return default
27
+ return res
@@ -0,0 +1,13 @@
1
+ def ask(question: str, default: None | str = None):
2
+ if default is not None:
3
+ question += f" [{default}]"
4
+ answer = input(question.strip() + " ").strip()
5
+ return answer if answer else default
6
+
7
+
8
+ def confirm(question: str, default: str = "n") -> bool:
9
+ prompt = f"{question} (y/n)"
10
+ answer = ask(prompt, default=default)
11
+ if not answer:
12
+ return False
13
+ return answer.lower().startswith("y")
@@ -1,11 +1,11 @@
1
1
  import json
2
- from keepercommander import vault as keeper_vault
2
+ from keepercommander import vault
3
3
  from keepercommander import api
4
4
  from time import sleep
5
5
  from keepercommander.params import KeeperParams
6
6
  from keepercommander.config_storage import loader
7
7
  import os
8
- from .util import ask
8
+ from .interactive import ask
9
9
  from pathlib import Path
10
10
  import webbrowser
11
11
  from keepercommander.auth import login_steps
@@ -13,8 +13,10 @@ from logging import getLogger
13
13
 
14
14
  logger = getLogger(__name__)
15
15
 
16
+ CONFIG_FILE = Path.home() / ".config" / "jwbmisc" / "keeper.json"
16
17
 
17
- class _MinimalKeeperUI:
18
+
19
+ class MinimalKeeperUI:
18
20
  def __init__(self):
19
21
  self.waiting_for_sso_data_key = False
20
22
 
@@ -65,19 +67,19 @@ class _MinimalKeeperUI:
65
67
  step.resume()
66
68
 
67
69
 
68
- def _extract_keeper_field(record, field: str) -> str | None:
69
- if isinstance(record, keeper_vault.TypedRecord):
70
- value = record.get_typed_field(field)
71
- if value is None:
72
- value = next((f for f in record.custom if f.label == field), None)
70
+ def extract_record_field(record, field: str) -> str | None:
71
+ if not isinstance(record, vault.TypedRecord):
72
+ raise TypeError("only TypedRecord is supported")
73
+
74
+ value = record.get_typed_field(field) or record.get_typed_field(None, field)
73
75
 
74
- if value and value.value:
75
- return value.value[0] if isinstance(value.value, list) else str(value.value)
76
+ if value and value.value:
77
+ return value.value[0] if isinstance(value.value, list) else str(value.value)
76
78
 
77
79
  return None
78
80
 
79
81
 
80
- def _perform_keeper_login(params):
82
+ def perform_login(params):
81
83
  if not params.user:
82
84
  user = os.environ.get("KEEPER_USERNAME", None)
83
85
  if user is None:
@@ -96,29 +98,28 @@ def _perform_keeper_login(params):
96
98
  params.server = server
97
99
 
98
100
  try:
99
- api.login(params, login_ui=_MinimalKeeperUI())
101
+ api.login(params, login_ui=MinimalKeeperUI())
100
102
  except KeyboardInterrupt:
101
103
  raise KeyError("\nKeeper login cancelled by user.") from None
102
104
  except Exception as e:
103
105
  raise KeyError(f"Keeper login failed: {e}") from e
104
106
 
105
107
 
106
- def get_keeper_password(record_uid: str, field_path: str) -> str:
107
- config_file = Path.home() / ".config" / "keeper" / "config.json"
108
- config_file.parent.mkdir(parents=True, exist_ok=True)
108
+ def get_password(record_uid: str, field_path: str) -> str:
109
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
109
110
 
110
- params = KeeperParams(config_filename=str(config_file))
111
+ params = KeeperParams(config_filename=str(CONFIG_FILE))
111
112
 
112
- if config_file.exists():
113
+ if CONFIG_FILE.exists():
113
114
  try:
114
- params.config = json.loads(config_file.read_text())
115
+ params.config = json.loads(CONFIG_FILE.read_text())
115
116
  loader.load_config_properties(params)
116
117
  if not params.session_token:
117
118
  raise ValueError("No session token")
118
119
  except Exception:
119
- _perform_keeper_login(params)
120
+ perform_login(params)
120
121
  else:
121
- _perform_keeper_login(params)
122
+ perform_login(params)
122
123
 
123
124
  try:
124
125
  api.sync_down(params)
@@ -126,11 +127,11 @@ def get_keeper_password(record_uid: str, field_path: str) -> str:
126
127
  raise KeyError(f"Failed to sync Keeper vault: {e}") from e
127
128
 
128
129
  try:
129
- record = keeper_vault.KeeperRecord.load(params, record_uid)
130
+ record = vault.KeeperRecord.load(params, record_uid)
130
131
  except Exception as e:
131
132
  raise KeyError(f"Record {record_uid} not found: {e}") from e
132
133
 
133
- value = _extract_keeper_field(record, field_path)
134
+ value = extract_record_field(record, field_path)
134
135
  if value is None:
135
136
  raise KeyError(f"Field '{field_path}' not found in record {record_uid}")
136
137
 
@@ -2,6 +2,8 @@ import subprocess as sp
2
2
  import os
3
3
  from pathlib import Path
4
4
 
5
+ PASS_BIN = os.environ.get("JWBMISC_PASS_BIN", "pass")
6
+
5
7
 
6
8
  def get_pass(*pass_keys: str):
7
9
  if not pass_keys:
@@ -48,7 +50,7 @@ def get_pass(*pass_keys: str):
48
50
 
49
51
 
50
52
  def _call_unix_pass(key, lnum=1):
51
- proc = sp.Popen(["pass", "show", key], stdout=sp.PIPE, stderr=sp.PIPE, encoding="utf-8")
53
+ proc = sp.Popen([PASS_BIN, "show", key], stdout=sp.PIPE, stderr=sp.PIPE, encoding="utf-8")
52
54
  value, stderr = proc.communicate()
53
55
 
54
56
  if proc.returncode != 0:
@@ -70,6 +72,6 @@ def _call_unix_pass(key, lnum=1):
70
72
 
71
73
 
72
74
  def _keeper_password(record_uid: str, field_path: str) -> str:
73
- from .keeper import get_keeper_password
75
+ from .keeper import get_password as keeper_get_password
74
76
 
75
- return get_keeper_password(record_uid, field_path)
77
+ return keeper_get_password(record_uid, field_path)
@@ -1,4 +1,6 @@
1
1
  import re
2
+ import random
3
+ import string
2
4
 
3
5
 
4
6
  def jinja_replace(s, config, relaxed: bool = False, delim: tuple[str, str] = ("{{", "}}")):
@@ -20,3 +22,21 @@ def jinja_replace(s, config, relaxed: bool = False, delim: tuple[str, str] = ("{
20
22
  raise KeyError(f"{k} is not in the supplied replacement variables")
21
23
 
22
24
  return re.sub(re.escape(delim[0]) + r"\s*(\w+)\s*" + re.escape(delim[1]), handle_match, s)
25
+
26
+
27
+ def randomsuffix(length: int):
28
+ letters = string.ascii_lowercase
29
+ return "".join(random.choice(letters) for _ in range(length))
30
+
31
+
32
+ def qw(s: str) -> list[str]:
33
+ return s.split()
34
+
35
+
36
+ def split_host(host: str) -> tuple[str | None, int | None]:
37
+ if not host:
38
+ return (None, None)
39
+ res = host.split(":", 1)
40
+ if len(res) == 1:
41
+ return (res[0], None)
42
+ return (res[0], int(res[1]))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jwbmisc
3
- Version: 0.0.3
3
+ Version: 0.0.5
4
4
  Summary: Misc util functions of jwb
5
5
  Author-email: Joachim Bargsten <jw@bargsten.org>
6
6
  License: Apache License
@@ -6,13 +6,14 @@ pyproject.toml
6
6
  release.sh
7
7
  src/jwbmisc/__init__.py
8
8
  src/jwbmisc/_version.py
9
+ src/jwbmisc/collection.py
9
10
  src/jwbmisc/exec.py
10
11
  src/jwbmisc/fs.py
12
+ src/jwbmisc/interactive.py
11
13
  src/jwbmisc/json.py
12
14
  src/jwbmisc/keeper.py
13
15
  src/jwbmisc/passwd.py
14
16
  src/jwbmisc/string.py
15
- src/jwbmisc/util.py
16
17
  src/jwbmisc.egg-info/PKG-INFO
17
18
  src/jwbmisc.egg-info/SOURCES.txt
18
19
  src/jwbmisc.egg-info/dependency_links.txt
@@ -21,10 +22,11 @@ src/jwbmisc.egg-info/top_level.txt
21
22
  tests/conftest.py
22
23
  tests/fzf
23
24
  tests/pass
25
+ tests/test_collection.py
24
26
  tests/test_exec.py
25
27
  tests/test_fs.py
28
+ tests/test_interactive.py
26
29
  tests/test_json.py
27
30
  tests/test_keeper.py
28
31
  tests/test_passwd.py
29
- tests/test_string.py
30
- tests/test_util.py
32
+ tests/test_string.py
@@ -17,12 +17,6 @@ def env_var():
17
17
  os.environ.update(original)
18
18
 
19
19
 
20
- @pytest.fixture
21
- def fake_pass(monkeypatch):
22
- tests_dir = Path(__file__).parent
23
- monkeypatch.setenv("PATH", f"{tests_dir}:{os.environ['PATH']}")
24
-
25
-
26
20
  @pytest.fixture
27
21
  def fake_fzf(monkeypatch):
28
22
  tests_dir = Path(__file__).parent
@@ -1,5 +1,5 @@
1
- #!/bin/bash
2
- # Fake pass script for testing run_cmd and passwd
1
+ #!/usr/bin/env bash
2
+
3
3
  case "$2" in
4
4
  "test/secret") echo "password123";;
5
5
  "test/multiline") printf "line1\nline2\nline3";;
@@ -0,0 +1,41 @@
1
+ import pytest
2
+ from jwbmisc.collection import goo
3
+
4
+
5
+ class TestGoo:
6
+ def test_simple_key(self):
7
+ d = {"a": 1}
8
+ assert goo(d, "a") == 1
9
+
10
+ def test_nested_keys(self):
11
+ d = {"a": {"b": {"c": 3}}}
12
+ assert goo(d, "a", "b", "c") == 3
13
+
14
+ def test_dot_notation(self):
15
+ d = {"a": {"b": 2}}
16
+ assert goo(d, "a.b") == 2
17
+
18
+ def test_list_index(self):
19
+ d = {"items": ["a", "b", "c"]}
20
+ assert goo(d, "items", 1) == "b"
21
+
22
+ def test_missing_key_returns_default(self):
23
+ d = {"a": 1}
24
+ assert goo(d, "b", default="missing") == "missing"
25
+
26
+ def test_missing_key_returns_none_by_default(self):
27
+ d = {"a": 1}
28
+ assert goo(d, "b") is None
29
+
30
+ def test_raise_on_default(self):
31
+ d = {"a": 1}
32
+ with pytest.raises(ValueError, match="does not exist"):
33
+ goo(d, "b", raise_on_default=True)
34
+
35
+ def test_none_in_path_returns_default(self):
36
+ d = {"a": None}
37
+ assert goo(d, "a", "b", default="missing") == "missing"
38
+
39
+ def test_mixed_keys_and_indices(self):
40
+ d = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
41
+ assert goo(d, "users", 0, "name") == "Alice"
@@ -1,27 +1,31 @@
1
+ # pyright: basic
1
2
  import subprocess
2
3
 
3
4
  import pytest
5
+ from pathlib import Path
4
6
 
5
7
  from jwbmisc.exec import run_cmd
6
8
 
9
+ PASS_BIN = str((Path(__file__).parent / "pass").absolute())
10
+
7
11
 
8
12
  class TestRunCmd:
9
- def test_capture_stdout(self, fake_pass):
10
- stdout, stderr = run_cmd(["pass", "show", "test/secret"], capture=True)
13
+ def test_capture_stdout(self):
14
+ stdout, stderr = run_cmd([PASS_BIN, "show", "test/secret"], capture=True)
11
15
  assert stdout.strip() == "password123"
12
16
  assert stderr == ""
13
17
 
14
- def test_capture_disabled_returns_none(self, fake_pass):
15
- result = run_cmd(["pass", "show", "test/secret"], capture=False)
18
+ def test_capture_disabled_returns_none(self):
19
+ result = run_cmd([PASS_BIN, "show", "test/secret"], capture=False)
16
20
  assert result is None
17
21
 
18
- def test_decode_true_returns_strings(self, fake_pass):
19
- stdout, stderr = run_cmd(["pass", "show", "test/secret"], capture=True, decode=True)
22
+ def test_decode_true_returns_strings(self):
23
+ stdout, stderr = run_cmd([PASS_BIN, "show", "test/secret"], capture=True, decode=True)
20
24
  assert isinstance(stdout, str)
21
25
  assert isinstance(stderr, str)
22
26
 
23
- def test_decode_false_returns_bytes(self, fake_pass):
24
- stdout, stderr = run_cmd(["pass", "show", "test/secret"], capture=True, decode=False)
27
+ def test_decode_false_returns_bytes(self):
28
+ stdout, stderr = run_cmd([PASS_BIN, "show", "test/secret"], capture=True, decode=False)
25
29
  assert isinstance(stdout, bytes)
26
30
  assert isinstance(stderr, bytes)
27
31
 
@@ -61,14 +65,14 @@ class TestRunCmd:
61
65
  result = run_cmd(["echo", "test"], dry_run=True, capture=True)
62
66
  assert result == ("", "")
63
67
 
64
- def test_called_process_error(self, fake_pass):
68
+ def test_called_process_error(self):
65
69
  with pytest.raises(subprocess.CalledProcessError) as exc_info:
66
- run_cmd(["pass", "show", "test/missing"], capture=True)
70
+ run_cmd([PASS_BIN, "show", "test/missing"], capture=True)
67
71
  assert exc_info.value.returncode == 1
68
72
 
69
- def test_sensitive_data_redacted(self, fake_pass):
73
+ def test_sensitive_data_redacted(self):
70
74
  with pytest.raises(subprocess.CalledProcessError) as exc_info:
71
- run_cmd(["pass", "show", "test/missing"], capture=True, contains_sensitive_data=True)
75
+ run_cmd([PASS_BIN, "show", "test/missing"], capture=True, contains_sensitive_data=True)
72
76
  assert exc_info.value.stdout == b"<redacted>"
73
77
  assert exc_info.value.stderr == b"<redacted>"
74
78
 
@@ -0,0 +1,49 @@
1
+ from jwbmisc.interactive import ask, confirm
2
+
3
+
4
+ class TestAsk:
5
+ def test_returns_user_input(self, monkeypatch):
6
+ monkeypatch.setattr("builtins.input", lambda _: "user_answer")
7
+ assert ask("Question?") == "user_answer"
8
+
9
+ def test_returns_default_on_empty_input(self, monkeypatch):
10
+ monkeypatch.setattr("builtins.input", lambda _: "")
11
+ assert ask("Question?", default="default_value") == "default_value"
12
+
13
+ def test_returns_none_when_no_default_and_empty(self, monkeypatch):
14
+ monkeypatch.setattr("builtins.input", lambda _: "")
15
+ assert ask("Question?") is None
16
+
17
+ def test_strips_input(self, monkeypatch):
18
+ monkeypatch.setattr("builtins.input", lambda _: " answer ")
19
+ assert ask("Question?") == "answer"
20
+
21
+
22
+ class TestConfirm:
23
+ def test_returns_true_for_y(self, monkeypatch):
24
+ monkeypatch.setattr("builtins.input", lambda _: "y")
25
+ assert confirm("Continue?") is True
26
+
27
+ def test_returns_true_for_yes(self, monkeypatch):
28
+ monkeypatch.setattr("builtins.input", lambda _: "yes")
29
+ assert confirm("Continue?") is True
30
+
31
+ def test_returns_true_for_Y(self, monkeypatch):
32
+ monkeypatch.setattr("builtins.input", lambda _: "Y")
33
+ assert confirm("Continue?") is True
34
+
35
+ def test_returns_false_for_n(self, monkeypatch):
36
+ monkeypatch.setattr("builtins.input", lambda _: "n")
37
+ assert confirm("Continue?") is False
38
+
39
+ def test_returns_false_for_no(self, monkeypatch):
40
+ monkeypatch.setattr("builtins.input", lambda _: "no")
41
+ assert confirm("Continue?") is False
42
+
43
+ def test_empty_uses_default_n(self, monkeypatch):
44
+ monkeypatch.setattr("builtins.input", lambda _: "")
45
+ assert confirm("Continue?", default="n") is False
46
+
47
+ def test_empty_uses_default_y(self, monkeypatch):
48
+ monkeypatch.setattr("builtins.input", lambda _: "")
49
+ assert confirm("Continue?", default="y") is True
@@ -1,69 +1,61 @@
1
+ # pyright: basic
1
2
  import pytest
2
3
 
4
+ from keepercommander import vault, utils
3
5
  from jwbmisc.keeper import (
4
- _MinimalKeeperUI,
5
- _extract_keeper_field,
6
- _perform_keeper_login,
7
- get_keeper_password,
6
+ MinimalKeeperUI,
7
+ extract_record_field,
8
+ perform_login,
9
+ get_password,
8
10
  )
9
11
 
10
12
 
11
- class MockTypedField:
12
- def __init__(self, value):
13
- self.value = value
14
- self.label = None
15
-
16
-
17
- class MockTypedRecord:
18
- def __init__(self, fields=None, custom=None):
19
- self._fields = fields or {}
20
- self.custom = custom or []
21
-
22
- def get_typed_field(self, name):
23
- return self._fields.get(name)
13
+ def gen_record(type_name="login"):
14
+ record = vault.TypedRecord()
15
+ record.type_name = type_name
16
+ record.record_uid = utils.generate_uid()
17
+ record.record_key = utils.generate_aes_key()
18
+ record.fields.append(
19
+ vault.TypedField.new_field(
20
+ field_type="password", field_label="AWS Secret Sauce", field_value=["password123"]
21
+ )
22
+ )
23
+ return record
24
24
 
25
25
 
26
26
  class TestExtractKeeperField:
27
- def test_typed_record_with_list_value(self, mocker):
28
- mocker.patch("jwbmisc.keeper.keeper_vault.TypedRecord", MockTypedRecord)
29
- field = MockTypedField(["password123"])
30
- record = MockTypedRecord(fields={"password": field})
27
+ def test_typed_record_with_list_value(self):
28
+ record = gen_record()
31
29
 
32
- result = _extract_keeper_field(record, "password")
30
+ result = extract_record_field(record, "password")
33
31
  assert result == "password123"
34
32
 
35
- def test_typed_record_with_string_value(self, mocker):
36
- mocker.patch("jwbmisc.keeper.keeper_vault.TypedRecord", MockTypedRecord)
37
- field = MockTypedField("single_value")
38
- record = MockTypedRecord(fields={"password": field})
39
-
40
- result = _extract_keeper_field(record, "password")
41
- assert result == "single_value"
42
-
43
- def test_typed_record_field_not_found(self, mocker):
44
- mocker.patch("jwbmisc.keeper.keeper_vault.TypedRecord", MockTypedRecord)
45
- record = MockTypedRecord(fields={})
46
-
47
- result = _extract_keeper_field(record, "missing")
33
+ def test_typed_record_field_not_found(self):
34
+ record = gen_record()
35
+ record.fields = []
36
+ result = extract_record_field(record, "missing")
48
37
  assert result is None
49
38
 
50
- def test_typed_record_custom_field(self, mocker):
51
- mocker.patch("jwbmisc.keeper.keeper_vault.TypedRecord", MockTypedRecord)
52
- custom_field = MockTypedField(["custom_value"])
53
- custom_field.label = "custom_field"
54
- record = MockTypedRecord(fields={}, custom=[custom_field])
55
-
56
- result = _extract_keeper_field(record, "custom_field")
57
- assert result == "custom_value"
39
+ def test_typed_record_custom_field(self):
40
+ record = gen_record()
41
+ record.custom.append(
42
+ vault.TypedField.new_field(
43
+ field_type="passwordcustom", field_label="AWS Custom Secret Sauce", field_value=["password3"]
44
+ )
45
+ )
46
+ result = extract_record_field(record, "passwordcustom")
47
+ assert result == "password3"
48
+ result = extract_record_field(record, "AWS Custom Secret Sauce")
49
+ assert result == "password3"
58
50
 
59
51
  def test_non_typed_record_returns_none(self):
60
- result = _extract_keeper_field(object(), "any_field")
61
- assert result is None
52
+ with pytest.raises(TypeError):
53
+ extract_record_field(object(), "any_field")
62
54
 
63
55
 
64
56
  class TestMinimalKeeperUI:
65
57
  def test_on_password_raises(self):
66
- ui = _MinimalKeeperUI()
58
+ ui = MinimalKeeperUI()
67
59
  with pytest.raises(KeyError, match="Password login not supported"):
68
60
  ui.on_password(None)
69
61
 
@@ -74,7 +66,7 @@ class TestMinimalKeeperUI:
74
66
  step = mocker.MagicMock()
75
67
  step.sso_login_url = "https://sso.example.com"
76
68
 
77
- ui = _MinimalKeeperUI()
69
+ ui = MinimalKeeperUI()
78
70
  ui.on_sso_redirect(step)
79
71
 
80
72
  mock_webbrowser.open_new_tab.assert_called_once_with("https://sso.example.com")
@@ -87,7 +79,7 @@ class TestMinimalKeeperUI:
87
79
  step = mocker.MagicMock()
88
80
  step.sso_login_url = "https://sso.example.com"
89
81
 
90
- ui = _MinimalKeeperUI()
82
+ ui = MinimalKeeperUI()
91
83
  with pytest.raises(ValueError, match="No SSO token"):
92
84
  ui.on_sso_redirect(step)
93
85
 
@@ -100,7 +92,7 @@ class TestMinimalKeeperUI:
100
92
  step = mocker.MagicMock()
101
93
  step.get_channels.return_value = [mock_channel]
102
94
 
103
- ui = _MinimalKeeperUI()
95
+ ui = MinimalKeeperUI()
104
96
  ui.on_two_factor(step)
105
97
 
106
98
  step.send_code.assert_called_once_with("channel_123", "123456")
@@ -113,12 +105,12 @@ class TestMinimalKeeperUI:
113
105
  step = mocker.MagicMock()
114
106
  step.get_channels.return_value = [mock_channel]
115
107
 
116
- ui = _MinimalKeeperUI()
108
+ ui = MinimalKeeperUI()
117
109
  with pytest.raises(ValueError, match="TOTP authenticator not available"):
118
110
  ui.on_two_factor(step)
119
111
 
120
112
  def test_on_device_approval_does_not_raise(self, mocker):
121
- ui = _MinimalKeeperUI()
113
+ ui = MinimalKeeperUI()
122
114
  ui.on_device_approval(None) # Should not raise
123
115
 
124
116
  def test_on_sso_data_key_first_call(self, mocker):
@@ -127,7 +119,7 @@ class TestMinimalKeeperUI:
127
119
 
128
120
  step = mocker.MagicMock()
129
121
 
130
- ui = _MinimalKeeperUI()
122
+ ui = MinimalKeeperUI()
131
123
  assert ui.waiting_for_sso_data_key is False
132
124
 
133
125
  ui.on_sso_data_key(step)
@@ -141,7 +133,7 @@ class TestMinimalKeeperUI:
141
133
  mock_sleep = mocker.patch("jwbmisc.keeper.sleep")
142
134
  step = mocker.MagicMock()
143
135
 
144
- ui = _MinimalKeeperUI()
136
+ ui = MinimalKeeperUI()
145
137
  ui.waiting_for_sso_data_key = True
146
138
 
147
139
  ui.on_sso_data_key(step)
@@ -159,7 +151,7 @@ class TestPerformKeeperLogin:
159
151
  params = mocker.MagicMock()
160
152
  params.user = None
161
153
 
162
- _perform_keeper_login(params)
154
+ perform_login(params)
163
155
 
164
156
  assert params.user == "user@example.com"
165
157
  assert params.server == "keepersecurity.com"
@@ -173,7 +165,7 @@ class TestPerformKeeperLogin:
173
165
  params = mocker.MagicMock()
174
166
  params.user = None
175
167
 
176
- _perform_keeper_login(params)
168
+ perform_login(params)
177
169
 
178
170
  assert params.user == "user@example.com"
179
171
  assert params.server == "keepersecurity.com"
@@ -188,7 +180,7 @@ class TestPerformKeeperLogin:
188
180
  params.user = None
189
181
 
190
182
  with pytest.raises(KeyError, match="cancelled by user"):
191
- _perform_keeper_login(params)
183
+ perform_login(params)
192
184
 
193
185
 
194
186
  class TestGetKeeperPassword:
@@ -200,15 +192,15 @@ class TestGetKeeperPassword:
200
192
  mocker.patch("jwbmisc.keeper.KeeperParams", return_value=mock_params)
201
193
 
202
194
  mock_api = mocker.patch("jwbmisc.keeper.api")
203
- mock_vault = mocker.patch("jwbmisc.keeper.keeper_vault")
195
+ mock_vault = mocker.patch("jwbmisc.keeper.vault")
204
196
 
205
197
  mock_record = mocker.MagicMock()
206
198
  mock_vault.KeeperRecord.load.return_value = mock_record
207
199
 
208
- mocker.patch("jwbmisc.keeper._extract_keeper_field", return_value="the_password")
209
- mocker.patch("jwbmisc.keeper._perform_keeper_login")
200
+ mocker.patch("jwbmisc.keeper.extract_record_field", return_value="the_password")
201
+ mocker.patch("jwbmisc.keeper.perform_login")
210
202
 
211
- result = get_keeper_password("RECORD123", "password")
203
+ result = get_password("RECORD123", "password")
212
204
 
213
205
  assert result == "the_password"
214
206
  mock_api.sync_down.assert_called_once()
@@ -221,14 +213,14 @@ class TestGetKeeperPassword:
221
213
  mocker.patch("jwbmisc.keeper.KeeperParams", return_value=mock_params)
222
214
 
223
215
  mocker.patch("jwbmisc.keeper.api")
224
- mock_vault = mocker.patch("jwbmisc.keeper.keeper_vault")
216
+ mock_vault = mocker.patch("jwbmisc.keeper.vault")
225
217
  mock_vault.KeeperRecord.load.return_value = mocker.MagicMock()
226
218
 
227
- mocker.patch("jwbmisc.keeper._extract_keeper_field", return_value=None)
228
- mocker.patch("jwbmisc.keeper._perform_keeper_login")
219
+ mocker.patch("jwbmisc.keeper.extract_record_field", return_value=None)
220
+ mocker.patch("jwbmisc.keeper.perform_login")
229
221
 
230
222
  with pytest.raises(KeyError, match="Field.*not found"):
231
- get_keeper_password("RECORD123", "missing_field")
223
+ get_password("RECORD123", "missing_field")
232
224
 
233
225
  def test_sync_failure_raises(self, mocker, tmp_path):
234
226
  mocker.patch("jwbmisc.keeper.Path.home", return_value=tmp_path)
@@ -240,7 +232,29 @@ class TestGetKeeperPassword:
240
232
  mock_api = mocker.patch("jwbmisc.keeper.api")
241
233
  mock_api.sync_down.side_effect = Exception("Sync failed")
242
234
 
243
- mocker.patch("jwbmisc.keeper._perform_keeper_login")
235
+ mocker.patch("jwbmisc.keeper.perform_login")
244
236
 
245
237
  with pytest.raises(KeyError, match="Failed to sync"):
246
- get_keeper_password("RECORD123", "password")
238
+ get_password("RECORD123", "password")
239
+
240
+ # def test_keeper_url(self, mocker):
241
+ # mock_keeper = mocker.patch("jwbmisc.passwd._keeper_password")
242
+ # mock_keeper.return_value = "keeper_password"
243
+
244
+ # result = pw.get_pass("keeper://RECORD123/field/password")
245
+ # assert result == "keeper_password"
246
+ # mock_keeper.assert_called_once_with("RECORD123", "field/password")
247
+
248
+ # def test_keeper_url_invalid_format_raises(self):
249
+ # with pytest.raises(KeyError, match="Invalid keeper:// format"):
250
+ # pw.get_pass("keeper://RECORD123")
251
+
252
+
253
+ # class TestKeeperPassword:
254
+ # def test_delegates_to_get_password(self, mocker):
255
+ # mock_get = mocker.patch("jwbmisc.keeper.get_password")
256
+ # mock_get.return_value = "keeper_secret"
257
+
258
+ # result = _keeper_password("RECORD_UID", "field/path")
259
+ # assert result == "keeper_secret"
260
+ # mock_get.assert_called_once_with("RECORD_UID", "field/path")
@@ -0,0 +1,84 @@
1
+ # pyright: basic
2
+ import pytest
3
+
4
+ import jwbmisc.passwd as pw
5
+ from pathlib import Path
6
+
7
+ pw.PASS_BIN = str((Path(__file__).parent / "pass").absolute())
8
+
9
+
10
+ class TestUnixPass:
11
+ def test_pass_url_single_line(self):
12
+ result = pw.get_pass("pass://test/secret")
13
+ assert result == "password123"
14
+
15
+ def test_pass_url_with_line_number(self):
16
+ result = pw.get_pass("pass://test/multiline?2")
17
+ assert result == "line2"
18
+
19
+ def test_pass_url_with_all_lines(self):
20
+ result = pw.get_pass("pass://test/multiline?0")
21
+ assert "line1" in result
22
+ assert "line2" in result
23
+ assert "line3" in result
24
+
25
+ def test_pass_url_with_invalid_line_number(self):
26
+ with pytest.raises(KeyError):
27
+ pw.get_pass("pass://test/multiline?99")
28
+
29
+
30
+ class TestGetPass:
31
+ def test_no_keys_raises(self):
32
+ with pytest.raises(ValueError):
33
+ pw.get_pass()
34
+
35
+ def test_pass_url_missing_raises(self):
36
+ with pytest.raises(KeyError, match="pass failed"):
37
+ pw.get_pass("pass://test/missing")
38
+
39
+ def test_env_url(self, env_var):
40
+ env_var(MY_PASSWORD="secret123")
41
+ result = pw.get_pass("env://MY_PASSWORD")
42
+ assert result == "secret123"
43
+
44
+ def test_env_url_with_slashes(self, env_var):
45
+ env_var(MY__NESTED__VAR="nested_value")
46
+ result = pw.get_pass("env://MY/NESTED/VAR")
47
+ assert result == "nested_value"
48
+
49
+ def test_env_url_missing_raises(self):
50
+ with pytest.raises(KeyError, match="is not in the env"):
51
+ pw.get_pass("env://NONEXISTENT_VAR_12345")
52
+
53
+ def test_file_url(self, tmp_path):
54
+ secret_file = tmp_path / "secret.txt"
55
+ secret_file.write_text("file_password\n")
56
+ result = pw.get_pass(f"file://{secret_file}")
57
+ assert result == "file_password"
58
+
59
+ def test_file_url_strips_whitespace(self, tmp_path):
60
+ secret_file = tmp_path / "secret.txt"
61
+ secret_file.write_text(" password \n\n")
62
+ result = pw.get_pass(f"file://{secret_file}")
63
+ assert result == "password"
64
+
65
+ def test_file_url_missing_raises(self, tmp_path):
66
+ missing = tmp_path / "missing.txt"
67
+ with pytest.raises(KeyError, match="does not exist"):
68
+ pw.get_pass(f"file://{missing}")
69
+
70
+ def test_file_url_directory_raises(self, tmp_path):
71
+ with pytest.raises(KeyError, match="does not exist or is a dir"):
72
+ pw.get_pass(f"file://{tmp_path}")
73
+
74
+ def test_keyring_url(self, mocker):
75
+ mocker.patch("keyring.get_password", return_value="keyring_password")
76
+
77
+ result = pw.get_pass("keyring://service/username")
78
+ assert result == "keyring_password"
79
+
80
+ def test_keyring_url_not_found_raises(self, mocker):
81
+ mocker.patch("keyring.get_password", return_value=None)
82
+
83
+ with pytest.raises(KeyError):
84
+ pw.get_pass("keyring://service/username")
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from jwbmisc.string import jinja_replace
3
+ from jwbmisc.string import jinja_replace, qw, split_host, randomsuffix
4
4
 
5
5
 
6
6
  class TestJinjaReplace:
@@ -43,3 +43,51 @@ class TestJinjaReplace:
43
43
  def test_repeated_variable(self):
44
44
  result = jinja_replace("{{x}} and {{x}}", {"x": "same"})
45
45
  assert result == "same and same"
46
+
47
+
48
+ class TestRandomsuffix:
49
+ def test_correct_length(self):
50
+ result = randomsuffix(10)
51
+ assert len(result) == 10
52
+
53
+ def test_only_lowercase(self):
54
+ result = randomsuffix(100)
55
+ assert result.islower()
56
+ assert result.isalpha()
57
+
58
+ def test_zero_length(self):
59
+ assert randomsuffix(0) == ""
60
+
61
+
62
+ class TestQw:
63
+ def test_splits_on_whitespace(self):
64
+ assert qw("a b c") == ["a", "b", "c"]
65
+
66
+ def test_splits_on_multiple_spaces(self):
67
+ assert qw("a b c") == ["a", "b", "c"]
68
+
69
+ def test_splits_on_tabs(self):
70
+ assert qw("a\tb\tc") == ["a", "b", "c"]
71
+
72
+ def test_splits_on_newlines(self):
73
+ assert qw("a\nb\nc") == ["a", "b", "c"]
74
+
75
+ def test_empty_string(self):
76
+ assert qw("") == []
77
+
78
+ def test_single_word(self):
79
+ assert qw("word") == ["word"]
80
+
81
+
82
+ class TestSplitHost:
83
+ def test_host_and_port(self):
84
+ assert split_host("localhost:8080") == ("localhost", 8080)
85
+
86
+ def test_host_only(self):
87
+ assert split_host("localhost") == ("localhost", None)
88
+
89
+ def test_empty_string(self):
90
+ assert split_host("") == (None, None)
91
+
92
+ def test_ipv4_with_port(self):
93
+ assert split_host("192.168.1.1:443") == ("192.168.1.1", 443)
@@ -1,64 +0,0 @@
1
- import random
2
- import string
3
- from typing import Any
4
-
5
-
6
- def ask(question, default=None):
7
- if default is not None:
8
- question += f" [{default}]"
9
- answer = input(question.strip() + " ").strip()
10
- return answer if answer else default
11
-
12
-
13
- def confirm(question, default="n"):
14
- prompt = f"{question} (y/n)"
15
- if default is not None:
16
- prompt += f" [{default}]"
17
- answer = input(prompt).strip().lower()
18
- if not answer:
19
- answer = default.lower() if default else "n"
20
- return answer.startswith("y")
21
-
22
-
23
- def randomsuffix(length):
24
- letters = string.ascii_lowercase
25
- return "".join(random.choice(letters) for _ in range(length))
26
-
27
-
28
- def qw(s: str) -> list[str]:
29
- return s.split()
30
-
31
-
32
- def split_host(host: str) -> tuple[str | None, int | None]:
33
- if not host:
34
- return (None, None)
35
- res = host.split(":", 1)
36
- if len(res) == 1:
37
- return (res[0], None)
38
- return (res[0], int(res[1]))
39
-
40
-
41
- def goo(
42
- d: dict[str, Any],
43
- *keys: str | int,
44
- default: Any | None = None,
45
- raise_on_default: bool = False,
46
- ):
47
- path = ".".join(str(k) for k in keys)
48
- parts = path.split(".")
49
-
50
- res = d
51
- for p in parts:
52
- if res is None:
53
- if raise_on_default:
54
- raise ValueError(f"'{path}' does not exist")
55
- return default
56
- if isinstance(res, (list, set, tuple)):
57
- res = res[int(p)]
58
- else:
59
- res = res.get(p)
60
- if res is None:
61
- if raise_on_default:
62
- raise ValueError(f"'{path}' does not exist")
63
- return default
64
- return res
@@ -1,121 +0,0 @@
1
- import pytest
2
-
3
- from jwbmisc.passwd import _call_unix_pass, _keeper_password, get_pass
4
-
5
-
6
- class TestGetPass:
7
- def test_no_keys_raises(self):
8
- with pytest.raises(ValueError, match="no pass keys"):
9
- get_pass()
10
-
11
- def test_pass_url_single_line(self, fake_pass):
12
- result = get_pass("pass://test/secret")
13
- assert result == "password123"
14
-
15
- def test_pass_url_with_line_number(self, fake_pass):
16
- result = get_pass("pass://test/multiline?2")
17
- assert result == "line2"
18
-
19
- def test_pass_url_missing_raises(self, fake_pass):
20
- with pytest.raises(KeyError, match="pass failed"):
21
- get_pass("pass://test/missing")
22
-
23
- def test_env_url(self, env_var):
24
- env_var(MY_PASSWORD="secret123")
25
- result = get_pass("env://MY_PASSWORD")
26
- assert result == "secret123"
27
-
28
- def test_env_url_with_slashes(self, env_var):
29
- env_var(MY__NESTED__VAR="nested_value")
30
- result = get_pass("env://MY/NESTED/VAR")
31
- assert result == "nested_value"
32
-
33
- def test_env_url_missing_raises(self):
34
- with pytest.raises(KeyError, match="is not in the env"):
35
- get_pass("env://NONEXISTENT_VAR_12345")
36
-
37
- def test_file_url(self, tmp_path):
38
- secret_file = tmp_path / "secret.txt"
39
- secret_file.write_text("file_password\n")
40
- result = get_pass(f"file://{secret_file}")
41
- assert result == "file_password"
42
-
43
- def test_file_url_strips_whitespace(self, tmp_path):
44
- secret_file = tmp_path / "secret.txt"
45
- secret_file.write_text(" password \n\n")
46
- result = get_pass(f"file://{secret_file}")
47
- assert result == "password"
48
-
49
- def test_file_url_missing_raises(self, tmp_path):
50
- missing = tmp_path / "missing.txt"
51
- with pytest.raises(KeyError, match="does not exist"):
52
- get_pass(f"file://{missing}")
53
-
54
- def test_file_url_directory_raises(self, tmp_path):
55
- with pytest.raises(KeyError, match="does not exist or is a dir"):
56
- get_pass(f"file://{tmp_path}")
57
-
58
- def test_keyring_url(self, mocker):
59
- mocker.patch.dict("sys.modules", {"keyring": mocker.MagicMock()})
60
- import sys
61
-
62
- sys.modules["keyring"].get_password.return_value = "keyring_password"
63
-
64
- result = get_pass("keyring://service/username")
65
- assert result == "keyring_password"
66
- sys.modules["keyring"].get_password.assert_called_once_with("service", "username")
67
-
68
- def test_keyring_url_not_found_raises(self, mocker):
69
- mocker.patch.dict("sys.modules", {"keyring": mocker.MagicMock()})
70
- import sys
71
-
72
- sys.modules["keyring"].get_password.return_value = None
73
-
74
- with pytest.raises(KeyError, match="could not find a password"):
75
- get_pass("keyring://service/username")
76
-
77
- def test_keeper_url(self, mocker):
78
- mock_keeper = mocker.patch("jwbmisc.passwd._keeper_password")
79
- mock_keeper.return_value = "keeper_password"
80
-
81
- result = get_pass("keeper://RECORD123/field/password")
82
- assert result == "keeper_password"
83
- mock_keeper.assert_called_once_with("RECORD123", "field/password")
84
-
85
- def test_keeper_url_invalid_format_raises(self):
86
- with pytest.raises(KeyError, match="Invalid keeper:// format"):
87
- get_pass("keeper://RECORD123")
88
-
89
-
90
- class TestCallUnixPass:
91
- def test_single_line(self, fake_pass):
92
- result = _call_unix_pass("test/secret")
93
- assert result == "password123"
94
-
95
- def test_get_specific_line(self, fake_pass):
96
- result = _call_unix_pass("test/multiline", lnum=2)
97
- assert result == "line2"
98
-
99
- def test_get_all_lines(self, fake_pass):
100
- result = _call_unix_pass("test/multiline", lnum=0)
101
- assert "line1" in result
102
- assert "line2" in result
103
- assert "line3" in result
104
-
105
- def test_error_raises_keyerror(self, fake_pass):
106
- with pytest.raises(KeyError, match="pass failed"):
107
- _call_unix_pass("test/missing")
108
-
109
- def test_line_number_out_of_range(self, fake_pass):
110
- with pytest.raises(KeyError, match="could not retrieve lines"):
111
- _call_unix_pass("test/secret", lnum=99)
112
-
113
-
114
- class TestKeeperPassword:
115
- def test_delegates_to_get_keeper_password(self, mocker):
116
- mock_get = mocker.patch("jwbmisc.keeper.get_keeper_password")
117
- mock_get.return_value = "keeper_secret"
118
-
119
- result = _keeper_password("RECORD_UID", "field/path")
120
- assert result == "keeper_secret"
121
- mock_get.assert_called_once_with("RECORD_UID", "field/path")
@@ -1,138 +0,0 @@
1
- import pytest
2
-
3
- from jwbmisc.util import ask, confirm, goo, qw, randomsuffix, split_host
4
-
5
-
6
- class TestAsk:
7
- def test_returns_user_input(self, monkeypatch):
8
- monkeypatch.setattr("builtins.input", lambda _: "user_answer")
9
- assert ask("Question?") == "user_answer"
10
-
11
- def test_returns_default_on_empty_input(self, monkeypatch):
12
- monkeypatch.setattr("builtins.input", lambda _: "")
13
- assert ask("Question?", default="default_value") == "default_value"
14
-
15
- def test_returns_none_when_no_default_and_empty(self, monkeypatch):
16
- monkeypatch.setattr("builtins.input", lambda _: "")
17
- assert ask("Question?") is None
18
-
19
- def test_strips_input(self, monkeypatch):
20
- monkeypatch.setattr("builtins.input", lambda _: " answer ")
21
- assert ask("Question?") == "answer"
22
-
23
-
24
- class TestConfirm:
25
- def test_returns_true_for_y(self, monkeypatch):
26
- monkeypatch.setattr("builtins.input", lambda _: "y")
27
- assert confirm("Continue?") is True
28
-
29
- def test_returns_true_for_yes(self, monkeypatch):
30
- monkeypatch.setattr("builtins.input", lambda _: "yes")
31
- assert confirm("Continue?") is True
32
-
33
- def test_returns_true_for_Y(self, monkeypatch):
34
- monkeypatch.setattr("builtins.input", lambda _: "Y")
35
- assert confirm("Continue?") is True
36
-
37
- def test_returns_false_for_n(self, monkeypatch):
38
- monkeypatch.setattr("builtins.input", lambda _: "n")
39
- assert confirm("Continue?") is False
40
-
41
- def test_returns_false_for_no(self, monkeypatch):
42
- monkeypatch.setattr("builtins.input", lambda _: "no")
43
- assert confirm("Continue?") is False
44
-
45
- def test_empty_uses_default_n(self, monkeypatch):
46
- monkeypatch.setattr("builtins.input", lambda _: "")
47
- assert confirm("Continue?", default="n") is False
48
-
49
- def test_empty_uses_default_y(self, monkeypatch):
50
- monkeypatch.setattr("builtins.input", lambda _: "")
51
- assert confirm("Continue?", default="y") is True
52
-
53
-
54
- class TestRandomsuffix:
55
- def test_correct_length(self):
56
- result = randomsuffix(10)
57
- assert len(result) == 10
58
-
59
- def test_only_lowercase(self):
60
- result = randomsuffix(100)
61
- assert result.islower()
62
- assert result.isalpha()
63
-
64
- def test_zero_length(self):
65
- assert randomsuffix(0) == ""
66
-
67
-
68
- class TestQw:
69
- def test_splits_on_whitespace(self):
70
- assert qw("a b c") == ["a", "b", "c"]
71
-
72
- def test_splits_on_multiple_spaces(self):
73
- assert qw("a b c") == ["a", "b", "c"]
74
-
75
- def test_splits_on_tabs(self):
76
- assert qw("a\tb\tc") == ["a", "b", "c"]
77
-
78
- def test_splits_on_newlines(self):
79
- assert qw("a\nb\nc") == ["a", "b", "c"]
80
-
81
- def test_empty_string(self):
82
- assert qw("") == []
83
-
84
- def test_single_word(self):
85
- assert qw("word") == ["word"]
86
-
87
-
88
- class TestSplitHost:
89
- def test_host_and_port(self):
90
- assert split_host("localhost:8080") == ("localhost", 8080)
91
-
92
- def test_host_only(self):
93
- assert split_host("localhost") == ("localhost", None)
94
-
95
- def test_empty_string(self):
96
- assert split_host("") == (None, None)
97
-
98
- def test_ipv4_with_port(self):
99
- assert split_host("192.168.1.1:443") == ("192.168.1.1", 443)
100
-
101
-
102
- class TestGoo:
103
- def test_simple_key(self):
104
- d = {"a": 1}
105
- assert goo(d, "a") == 1
106
-
107
- def test_nested_keys(self):
108
- d = {"a": {"b": {"c": 3}}}
109
- assert goo(d, "a", "b", "c") == 3
110
-
111
- def test_dot_notation(self):
112
- d = {"a": {"b": 2}}
113
- assert goo(d, "a.b") == 2
114
-
115
- def test_list_index(self):
116
- d = {"items": ["a", "b", "c"]}
117
- assert goo(d, "items", 1) == "b"
118
-
119
- def test_missing_key_returns_default(self):
120
- d = {"a": 1}
121
- assert goo(d, "b", default="missing") == "missing"
122
-
123
- def test_missing_key_returns_none_by_default(self):
124
- d = {"a": 1}
125
- assert goo(d, "b") is None
126
-
127
- def test_raise_on_default(self):
128
- d = {"a": 1}
129
- with pytest.raises(ValueError, match="does not exist"):
130
- goo(d, "b", raise_on_default=True)
131
-
132
- def test_none_in_path_returns_default(self):
133
- d = {"a": None}
134
- assert goo(d, "a", "b", default="missing") == "missing"
135
-
136
- def test_mixed_keys_and_indices(self):
137
- d = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
138
- assert goo(d, "users", 0, "name") == "Alice"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes