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.
Files changed (37) hide show
  1. {jwbmisc-0.0.3/src/jwbmisc.egg-info → jwbmisc-0.0.4}/PKG-INFO +1 -1
  2. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/__init__.py +3 -2
  3. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/_version.py +3 -3
  4. jwbmisc-0.0.4/src/jwbmisc/collection.py +27 -0
  5. jwbmisc-0.0.4/src/jwbmisc/interactive.py +13 -0
  6. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/keeper.py +1 -1
  7. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/passwd.py +3 -1
  8. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/string.py +20 -0
  9. {jwbmisc-0.0.3 → jwbmisc-0.0.4/src/jwbmisc.egg-info}/PKG-INFO +1 -1
  10. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/SOURCES.txt +5 -3
  11. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/conftest.py +0 -6
  12. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/pass +2 -2
  13. jwbmisc-0.0.4/tests/test_collection.py +41 -0
  14. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_exec.py +16 -12
  15. jwbmisc-0.0.4/tests/test_interactive.py +49 -0
  16. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_keeper.py +22 -0
  17. jwbmisc-0.0.4/tests/test_passwd.py +84 -0
  18. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/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.4}/LICENSE +0 -0
  23. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/MANIFEST.in +0 -0
  24. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/Makefile +0 -0
  25. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/README.md +0 -0
  26. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/pyproject.toml +0 -0
  27. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/release.sh +0 -0
  28. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/setup.cfg +0 -0
  29. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/exec.py +0 -0
  30. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/fs.py +0 -0
  31. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc/json.py +0 -0
  32. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/dependency_links.txt +0 -0
  33. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/requires.txt +0 -0
  34. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/src/jwbmisc.egg-info/top_level.txt +0 -0
  35. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/fzf +0 -0
  36. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/tests/test_fs.py +0 -0
  37. {jwbmisc-0.0.3 → jwbmisc-0.0.4}/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.4
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.4'
32
+ __version_tuple__ = version_tuple = (0, 0, 4)
33
33
 
34
- __commit_id__ = commit_id = 'g75f9cb167'
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 .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
@@ -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:
@@ -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.4
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
@@ -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")
@@ -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