jwbmisc 0.0.3__tar.gz → 0.0.4__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.4}/PKG-INFO +1 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/__init__.py +3 -2
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/_version.py +3 -3
- jwbmisc-0.0.4/src/jwbmisc/collection.py +27 -0
- jwbmisc-0.0.4/src/jwbmisc/interactive.py +13 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/keeper.py +1 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/passwd.py +3 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/string.py +20 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4/src/jwbmisc.egg-info}/PKG-INFO +1 -1
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/SOURCES.txt +5 -3
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/conftest.py +0 -6
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/pass +2 -2
- jwbmisc-0.0.4/tests/test_collection.py +41 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_exec.py +16 -12
- jwbmisc-0.0.4/tests/test_interactive.py +49 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_keeper.py +22 -0
- jwbmisc-0.0.4/tests/test_passwd.py +84 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/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.4}/LICENSE +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/MANIFEST.in +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/Makefile +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/README.md +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/pyproject.toml +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/release.sh +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/setup.cfg +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/exec.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/fs.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/json.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/dependency_links.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/requires.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/top_level.txt +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/fzf +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_fs.py +0 -0
- {jwbmisc-0.0.3 → jwbmisc-0.0.4}/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.4'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 4)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'gcba41a5a3'
|
|
@@ -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")
|
|
@@ -5,7 +5,7 @@ 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
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -244,3 +244,25 @@ class TestGetKeeperPassword:
|
|
|
244
244
|
|
|
245
245
|
with pytest.raises(KeyError, match="Failed to sync"):
|
|
246
246
|
get_keeper_password("RECORD123", "password")
|
|
247
|
+
|
|
248
|
+
# def test_keeper_url(self, mocker):
|
|
249
|
+
# mock_keeper = mocker.patch("jwbmisc.passwd._keeper_password")
|
|
250
|
+
# mock_keeper.return_value = "keeper_password"
|
|
251
|
+
|
|
252
|
+
# result = pw.get_pass("keeper://RECORD123/field/password")
|
|
253
|
+
# assert result == "keeper_password"
|
|
254
|
+
# mock_keeper.assert_called_once_with("RECORD123", "field/password")
|
|
255
|
+
|
|
256
|
+
# def test_keeper_url_invalid_format_raises(self):
|
|
257
|
+
# with pytest.raises(KeyError, match="Invalid keeper:// format"):
|
|
258
|
+
# pw.get_pass("keeper://RECORD123")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# class TestKeeperPassword:
|
|
262
|
+
# def test_delegates_to_get_keeper_password(self, mocker):
|
|
263
|
+
# mock_get = mocker.patch("jwbmisc.keeper.get_keeper_password")
|
|
264
|
+
# mock_get.return_value = "keeper_secret"
|
|
265
|
+
|
|
266
|
+
# result = _keeper_password("RECORD_UID", "field/path")
|
|
267
|
+
# assert result == "keeper_secret"
|
|
268
|
+
# 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
|