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.
- {jwbmisc-0.0.3/src/jwbmisc.egg-info → jwbmisc-0.0.5}/PKG-INFO +1 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/__init__.py +3 -2
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/_version.py +3 -3
- jwbmisc-0.0.5/src/jwbmisc/collection.py +27 -0
- jwbmisc-0.0.5/src/jwbmisc/interactive.py +13 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/keeper.py +23 -22
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/passwd.py +5 -3
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/string.py +20 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5/src/jwbmisc.egg-info}/PKG-INFO +1 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/SOURCES.txt +5 -3
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/conftest.py +0 -6
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/pass +2 -2
- jwbmisc-0.0.5/tests/test_collection.py +41 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_exec.py +16 -12
- jwbmisc-0.0.5/tests/test_interactive.py +49 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_keeper.py +80 -66
- jwbmisc-0.0.5/tests/test_passwd.py +84 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_string.py +49 -1
- jwbmisc-0.0.3/src/jwbmisc/util.py +0 -64
- jwbmisc-0.0.3/tests/test_passwd.py +0 -121
- jwbmisc-0.0.3/tests/test_util.py +0 -138
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/LICENSE +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/MANIFEST.in +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/Makefile +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/README.md +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/pyproject.toml +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/release.sh +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/setup.cfg +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/exec.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/fs.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc/json.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/dependency_links.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/requires.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/src/jwbmisc.egg-info/top_level.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/fzf +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_fs.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.5}/tests/test_json.py +0 -0
|
@@ -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 .
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.5'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 5)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
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
|
|
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 .
|
|
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
|
-
|
|
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
|
|
69
|
-
if isinstance(record,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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=
|
|
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
|
|
107
|
-
|
|
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(
|
|
111
|
+
params = KeeperParams(config_filename=str(CONFIG_FILE))
|
|
111
112
|
|
|
112
|
-
if
|
|
113
|
+
if CONFIG_FILE.exists():
|
|
113
114
|
try:
|
|
114
|
-
params.config = json.loads(
|
|
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
|
-
|
|
120
|
+
perform_login(params)
|
|
120
121
|
else:
|
|
121
|
-
|
|
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 =
|
|
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 =
|
|
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([
|
|
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
|
|
75
|
+
from .keeper import get_password as keeper_get_password
|
|
74
76
|
|
|
75
|
-
return
|
|
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]))
|
|
@@ -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
|
|
@@ -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
|
|
10
|
-
stdout, stderr = run_cmd([
|
|
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
|
|
15
|
-
result = run_cmd([
|
|
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
|
|
19
|
-
stdout, stderr = run_cmd([
|
|
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
|
|
24
|
-
stdout, stderr = run_cmd([
|
|
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
|
|
68
|
+
def test_called_process_error(self):
|
|
65
69
|
with pytest.raises(subprocess.CalledProcessError) as exc_info:
|
|
66
|
-
run_cmd([
|
|
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
|
|
73
|
+
def test_sensitive_data_redacted(self):
|
|
70
74
|
with pytest.raises(subprocess.CalledProcessError) as exc_info:
|
|
71
|
-
run_cmd([
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
MinimalKeeperUI,
|
|
7
|
+
extract_record_field,
|
|
8
|
+
perform_login,
|
|
9
|
+
get_password,
|
|
8
10
|
)
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
28
|
-
|
|
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 =
|
|
30
|
+
result = extract_record_field(record, "password")
|
|
33
31
|
assert result == "password123"
|
|
34
32
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
209
|
-
mocker.patch("jwbmisc.keeper.
|
|
200
|
+
mocker.patch("jwbmisc.keeper.extract_record_field", return_value="the_password")
|
|
201
|
+
mocker.patch("jwbmisc.keeper.perform_login")
|
|
210
202
|
|
|
211
|
-
result =
|
|
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.
|
|
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.
|
|
228
|
-
mocker.patch("jwbmisc.keeper.
|
|
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
|
-
|
|
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.
|
|
235
|
+
mocker.patch("jwbmisc.keeper.perform_login")
|
|
244
236
|
|
|
245
237
|
with pytest.raises(KeyError, match="Failed to sync"):
|
|
246
|
-
|
|
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")
|
jwbmisc-0.0.3/tests/test_util.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|