jwbmisc 0.0.1__tar.gz → 0.0.3__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 (34) hide show
  1. {jwbmisc-0.0.1/src/jwbmisc.egg-info → jwbmisc-0.0.3}/PKG-INFO +3 -2
  2. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/pyproject.toml +2 -1
  3. jwbmisc-0.0.3/src/jwbmisc/__init__.py +25 -0
  4. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/src/jwbmisc/_version.py +16 -3
  5. jwbmisc-0.0.3/src/jwbmisc/exec.py +50 -0
  6. jwbmisc-0.0.3/src/jwbmisc/fs.py +28 -0
  7. jwbmisc-0.0.3/src/jwbmisc/json.py +46 -0
  8. jwbmisc-0.0.3/src/jwbmisc/keeper.py +137 -0
  9. jwbmisc-0.0.3/src/jwbmisc/passwd.py +75 -0
  10. jwbmisc-0.0.3/src/jwbmisc/string.py +22 -0
  11. jwbmisc-0.0.3/src/jwbmisc/util.py +64 -0
  12. {jwbmisc-0.0.1 → jwbmisc-0.0.3/src/jwbmisc.egg-info}/PKG-INFO +3 -2
  13. jwbmisc-0.0.3/src/jwbmisc.egg-info/SOURCES.txt +30 -0
  14. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/src/jwbmisc.egg-info/requires.txt +2 -1
  15. jwbmisc-0.0.3/tests/conftest.py +29 -0
  16. jwbmisc-0.0.3/tests/fzf +4 -0
  17. jwbmisc-0.0.3/tests/pass +8 -0
  18. jwbmisc-0.0.3/tests/test_exec.py +77 -0
  19. jwbmisc-0.0.3/tests/test_fs.py +54 -0
  20. jwbmisc-0.0.3/tests/test_json.py +118 -0
  21. jwbmisc-0.0.3/tests/test_keeper.py +246 -0
  22. jwbmisc-0.0.3/tests/test_passwd.py +121 -0
  23. jwbmisc-0.0.3/tests/test_string.py +45 -0
  24. jwbmisc-0.0.3/tests/test_util.py +138 -0
  25. jwbmisc-0.0.1/src/jwbmisc/__init__.py +0 -233
  26. jwbmisc-0.0.1/src/jwbmisc.egg-info/SOURCES.txt +0 -13
  27. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/LICENSE +0 -0
  28. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/MANIFEST.in +0 -0
  29. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/Makefile +0 -0
  30. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/README.md +0 -0
  31. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/release.sh +0 -0
  32. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/setup.cfg +0 -0
  33. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/src/jwbmisc.egg-info/dependency_links.txt +0 -0
  34. {jwbmisc-0.0.1 → jwbmisc-0.0.3}/src/jwbmisc.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jwbmisc
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Misc util functions of jwb
5
5
  Author-email: Joachim Bargsten <jw@bargsten.org>
6
6
  License: Apache License
@@ -212,7 +212,6 @@ Description-Content-Type: text/markdown
212
212
  License-File: LICENSE
213
213
  Requires-Dist: pyyaml
214
214
  Requires-Dist: more-itertools
215
- Requires-Dist: keyring
216
215
  Provides-Extra: dev
217
216
  Requires-Dist: ruff; extra == "dev"
218
217
  Requires-Dist: pytest>=7; extra == "dev"
@@ -220,6 +219,8 @@ Requires-Dist: setuptools_scm; extra == "dev"
220
219
  Requires-Dist: mypy; extra == "dev"
221
220
  Requires-Dist: pytest-env; extra == "dev"
222
221
  Requires-Dist: pytest-mock; extra == "dev"
222
+ Requires-Dist: keepercommander; extra == "dev"
223
+ Requires-Dist: keyring; extra == "dev"
223
224
  Provides-Extra: build
224
225
  Requires-Dist: build; extra == "build"
225
226
  Requires-Dist: twine; extra == "build"
@@ -25,7 +25,6 @@ authors = [{ name = "Joachim Bargsten", email = "jw@bargsten.org" }]
25
25
  dependencies = [
26
26
  "pyyaml",
27
27
  "more-itertools",
28
- "keyring"
29
28
  ]
30
29
 
31
30
  [project.urls]
@@ -40,6 +39,8 @@ dev = [
40
39
  "mypy",
41
40
  "pytest-env",
42
41
  "pytest-mock",
42
+ "keepercommander",
43
+ "keyring"
43
44
  ]
44
45
  build = [
45
46
  "build",
@@ -0,0 +1,25 @@
1
+ from .passwd import get_pass
2
+ from .string import jinja_replace
3
+ from .exec import run_cmd
4
+ from .json import jsonc_loads, jsonc_read, ndjson_read, ndjson_write, resilient_loads
5
+ from .fs import fzf, find_root
6
+ from .util import goo, ask, confirm, randomsuffix, qw, split_host
7
+
8
+ __all__ = [
9
+ "get_pass",
10
+ "jinja_replace",
11
+ "run_cmd",
12
+ "jsonc_loads",
13
+ "jsonc_read",
14
+ "ndjson_read",
15
+ "ndjson_write",
16
+ "resilient_loads",
17
+ "fzf",
18
+ "find_root",
19
+ "goo",
20
+ "ask",
21
+ "confirm",
22
+ "randomsuffix",
23
+ "qw",
24
+ "split_host",
25
+ ]
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.0.1'
21
- __version_tuple__ = version_tuple = (0, 0, 1)
31
+ __version__ = version = '0.0.3'
32
+ __version_tuple__ = version_tuple = (0, 0, 3)
33
+
34
+ __commit_id__ = commit_id = 'g75f9cb167'
@@ -0,0 +1,50 @@
1
+ import subprocess as sp
2
+ import os
3
+
4
+
5
+ def run_cmd(
6
+ cmd,
7
+ env=None,
8
+ capture=False,
9
+ stdin=None,
10
+ contains_sensitive_data=False,
11
+ timeout=20,
12
+ decode=True,
13
+ dry_run=False,
14
+ ):
15
+ if env is None:
16
+ env = {}
17
+ env = {**os.environ, **env}
18
+ env.pop("__PYVENV_LAUNCHER__", None)
19
+
20
+ if stdin is not None:
21
+ stdin = stdin.encode("utf-8")
22
+
23
+ cmd = [str(v) for v in cmd]
24
+
25
+ if dry_run:
26
+ print(cmd)
27
+ if capture:
28
+ return ("", "")
29
+ return
30
+
31
+ try:
32
+ res = sp.run(
33
+ cmd,
34
+ capture_output=capture,
35
+ env=env,
36
+ check=True,
37
+ timeout=timeout,
38
+ input=stdin,
39
+ )
40
+ except sp.CalledProcessError as ex:
41
+ redacted_bytes = "<redacted>".encode("utf-8")
42
+ out = redacted_bytes if contains_sensitive_data else ex.output
43
+ err = redacted_bytes if contains_sensitive_data else ex.stderr
44
+ raise sp.CalledProcessError(ex.returncode, ex.cmd, out, err) from None
45
+
46
+ if not capture:
47
+ return None
48
+ if decode:
49
+ return (res.stdout.decode("utf-8"), res.stderr.decode("utf-8"))
50
+ return (res.stdout, res.stderr)
@@ -0,0 +1,28 @@
1
+ import subprocess as sp
2
+ from collections.abc import Iterable
3
+ from pathlib import Path
4
+
5
+
6
+ def fzf(entries: Iterable[str]):
7
+ process = sp.Popen(
8
+ ["fzf", "+m"],
9
+ stdout=sp.PIPE,
10
+ stdin=sp.PIPE,
11
+ encoding="utf-8",
12
+ )
13
+
14
+ stdout, _ = process.communicate(input="\n".join(entries) + "\n")
15
+ return stdout.strip()
16
+
17
+
18
+ def find_root(start, req):
19
+ p = Path(start).absolute()
20
+ if p.is_file():
21
+ p = p.parent
22
+
23
+ while p.parent != p:
24
+ files = {f.name for f in p.iterdir()}
25
+ if req <= files:
26
+ return p
27
+ p = p.parent
28
+ return None
@@ -0,0 +1,46 @@
1
+ import re
2
+ from typing import Any
3
+ import json
4
+ from pathlib import Path
5
+ import gzip
6
+
7
+
8
+ def jsonc_loads(data: str):
9
+ data = re.sub(r"//.*$", "", data, flags=re.MULTILINE)
10
+ data = re.sub(r"/\*.*?\*/", "", data, flags=re.DOTALL)
11
+ return json.loads(data)
12
+
13
+
14
+ def jsonc_read(f: str | Path):
15
+ f = Path(f)
16
+ open_fn = gzip.open if f.suffix.lower() == ".gz" else open
17
+ with open_fn(f, "rt", encoding="utf-8") as fd:
18
+ return jsonc_loads(fd.read())
19
+
20
+
21
+ def ndjson_read(f: str | Path):
22
+ f = Path(f)
23
+ open_fn = gzip.open if f.suffix.lower() == ".gz" else open
24
+ with open_fn(f, "rt", encoding="utf-8") as fd:
25
+ for line in fd:
26
+ line = line.strip()
27
+ if line and not line.startswith("#"):
28
+ yield json.loads(line)
29
+
30
+
31
+ def ndjson_write(data: list[Any], f: str | Path):
32
+ f = Path(f)
33
+ open_fn = gzip.open if f.suffix.lower() == ".gz" else open
34
+ with open_fn(f, "wb") as fd:
35
+ for record in data:
36
+ blob = (json.dumps(record) + "\n").encode("utf-8")
37
+ fd.write(blob)
38
+
39
+
40
+ def resilient_loads(data):
41
+ if not data:
42
+ return None
43
+ try:
44
+ return json.loads(data)
45
+ except Exception:
46
+ return None
@@ -0,0 +1,137 @@
1
+ import json
2
+ from keepercommander import vault as keeper_vault
3
+ from keepercommander import api
4
+ from time import sleep
5
+ from keepercommander.params import KeeperParams
6
+ from keepercommander.config_storage import loader
7
+ import os
8
+ from .util import ask
9
+ from pathlib import Path
10
+ import webbrowser
11
+ from keepercommander.auth import login_steps
12
+ from logging import getLogger
13
+
14
+ logger = getLogger(__name__)
15
+
16
+
17
+ class _MinimalKeeperUI:
18
+ def __init__(self):
19
+ self.waiting_for_sso_data_key = False
20
+
21
+ def on_sso_redirect(self, step):
22
+ self.waiting_for_sso_data_key = False
23
+ webbrowser.open_new_tab(step.sso_login_url)
24
+
25
+ token = ask("SSO token: ")
26
+
27
+ if not token:
28
+ raise ValueError("No SSO token provided")
29
+ step.set_sso_token(token.strip())
30
+
31
+ def on_two_factor(self, step):
32
+ channels = step.get_channels()
33
+ totp_channel = next(
34
+ (c for c in channels if c.channel_type == login_steps.TwoFactorChannel.Authenticator), None
35
+ )
36
+
37
+ if not totp_channel:
38
+ raise ValueError("TOTP authenticator not available")
39
+
40
+ totp_code = ask("2FA code:")
41
+
42
+ if not totp_code:
43
+ raise ValueError("No TOTP code provided")
44
+
45
+ step.duration = login_steps.TwoFactorDuration.Every12Hours
46
+ try:
47
+ step.send_code(totp_channel.channel_uid, totp_code.strip())
48
+ logger.info("keeper 2fa success")
49
+ except api.KeeperApiError:
50
+ logger.info("keeper api error")
51
+
52
+ def on_password(self, _step):
53
+ raise KeyError("Password login not supported. Use SSO.")
54
+
55
+ def on_device_approval(self, _step):
56
+ logger.info("Waiting for device approval...")
57
+
58
+ def on_sso_data_key(self, step):
59
+ if not self.waiting_for_sso_data_key:
60
+ logger.info("sent push")
61
+ step.request_data_key(login_steps.DataKeyShareChannel.KeeperPush)
62
+ self.waiting_for_sso_data_key = True
63
+ logger.info("waiting for push")
64
+ sleep(1)
65
+ step.resume()
66
+
67
+
68
+ def _extract_keeper_field(record, field: str) -> str | None:
69
+ if isinstance(record, keeper_vault.TypedRecord):
70
+ value = record.get_typed_field(field)
71
+ if value is None:
72
+ value = next((f for f in record.custom if f.label == field), None)
73
+
74
+ if value and value.value:
75
+ return value.value[0] if isinstance(value.value, list) else str(value.value)
76
+
77
+ return None
78
+
79
+
80
+ def _perform_keeper_login(params):
81
+ if not params.user:
82
+ user = os.environ.get("KEEPER_USERNAME", None)
83
+ if user is None:
84
+ user = ask("User (email): ")
85
+ user = user.strip()
86
+ if not user:
87
+ raise ValueError("No username provided")
88
+ params.user = user
89
+
90
+ server = os.environ.get("KEEPER_SERVER", None)
91
+ if server is None:
92
+ server = ask("Server:")
93
+
94
+ if not server:
95
+ raise ValueError("No server provided")
96
+ params.server = server
97
+
98
+ try:
99
+ api.login(params, login_ui=_MinimalKeeperUI())
100
+ except KeyboardInterrupt:
101
+ raise KeyError("\nKeeper login cancelled by user.") from None
102
+ except Exception as e:
103
+ raise KeyError(f"Keeper login failed: {e}") from e
104
+
105
+
106
+ def get_keeper_password(record_uid: str, field_path: str) -> str:
107
+ config_file = Path.home() / ".config" / "keeper" / "config.json"
108
+ config_file.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ params = KeeperParams(config_filename=str(config_file))
111
+
112
+ if config_file.exists():
113
+ try:
114
+ params.config = json.loads(config_file.read_text())
115
+ loader.load_config_properties(params)
116
+ if not params.session_token:
117
+ raise ValueError("No session token")
118
+ except Exception:
119
+ _perform_keeper_login(params)
120
+ else:
121
+ _perform_keeper_login(params)
122
+
123
+ try:
124
+ api.sync_down(params)
125
+ except Exception as e:
126
+ raise KeyError(f"Failed to sync Keeper vault: {e}") from e
127
+
128
+ try:
129
+ record = keeper_vault.KeeperRecord.load(params, record_uid)
130
+ except Exception as e:
131
+ raise KeyError(f"Record {record_uid} not found: {e}") from e
132
+
133
+ value = _extract_keeper_field(record, field_path)
134
+ if value is None:
135
+ raise KeyError(f"Field '{field_path}' not found in record {record_uid}")
136
+
137
+ return value
@@ -0,0 +1,75 @@
1
+ import subprocess as sp
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ def get_pass(*pass_keys: str):
7
+ if not pass_keys:
8
+ raise ValueError("no pass keys supplied")
9
+
10
+ for pass_key in pass_keys:
11
+ if pass_key.startswith("pass://"):
12
+ k = pass_key.removeprefix("pass://")
13
+ lnum = 1
14
+ if "?" in k:
15
+ k, lnum = k.rsplit("?", 1)
16
+ return _call_unix_pass(k, int(lnum))
17
+
18
+ if pass_key.startswith("env://"):
19
+ env_var = pass_key.removeprefix("env://").replace("/", "__")
20
+ if env_var not in os.environ:
21
+ raise KeyError(f"{env_var} (derived from {pass_key}) is not in the env")
22
+ return os.environ[env_var]
23
+
24
+ if pass_key.startswith("file://"):
25
+ f = Path(pass_key.removeprefix("file://"))
26
+ if not f.exists() or f.is_dir():
27
+ raise KeyError(f"{f} (derived from {pass_key}) does not exist or is a dir")
28
+ return f.read_text().strip()
29
+
30
+ if pass_key.startswith("keyring://"):
31
+ import keyring
32
+
33
+ args = pass_key.removeprefix("keyring://").split("/")
34
+ pw = keyring.get_password(*args)
35
+ if pw is None:
36
+ raise KeyError(f"could not find a password for {pass_key}")
37
+ return pw
38
+
39
+ if pass_key.startswith("keeper://"):
40
+ path = pass_key.removeprefix("keeper://")
41
+ if "/" not in path:
42
+ raise KeyError("Invalid keeper:// format. Expected: keeper://RECORD_UID/field/fieldname")
43
+
44
+ record_uid, field_path = path.split("/", 1)
45
+ return _keeper_password(record_uid, field_path)
46
+
47
+ raise KeyError(f"Could not acquire password from one of {pass_keys}")
48
+
49
+
50
+ def _call_unix_pass(key, lnum=1):
51
+ proc = sp.Popen(["pass", "show", key], stdout=sp.PIPE, stderr=sp.PIPE, encoding="utf-8")
52
+ value, stderr = proc.communicate()
53
+
54
+ if proc.returncode != 0:
55
+ raise KeyError(f"pass failed for '{key}': {stderr.strip()}")
56
+
57
+ if lnum is None or lnum == 0:
58
+ return value.strip()
59
+ lines = value.splitlines()
60
+
61
+ try:
62
+ if isinstance(lnum, list):
63
+ pw = [lines[ln - 1].strip() for ln in lnum]
64
+ else:
65
+ pw = lines[lnum - 1].strip()
66
+ except IndexError:
67
+ raise KeyError(f"could not retrieve lines {lnum} for {key}")
68
+
69
+ return pw
70
+
71
+
72
+ def _keeper_password(record_uid: str, field_path: str) -> str:
73
+ from .keeper import get_keeper_password
74
+
75
+ return get_keeper_password(record_uid, field_path)
@@ -0,0 +1,22 @@
1
+ import re
2
+
3
+
4
+ def jinja_replace(s, config, relaxed: bool = False, delim: tuple[str, str] = ("{{", "}}")):
5
+ """Jinja for poor people. A very simple
6
+ function to replace variables in text using `{{variable}}` syntax.
7
+
8
+ :param s: the template string/text
9
+ :param config: a dict of variable -> replacement mapping
10
+ :param relaxed: Don't raise a KeyError if a variable is not in the config dict.
11
+ :param delim: Change the delimiters to something else.
12
+ """
13
+
14
+ def handle_match(m):
15
+ k = m.group(1)
16
+ if k in config:
17
+ return config[k]
18
+ if relaxed:
19
+ return m.group(0)
20
+ raise KeyError(f"{k} is not in the supplied replacement variables")
21
+
22
+ return re.sub(re.escape(delim[0]) + r"\s*(\w+)\s*" + re.escape(delim[1]), handle_match, s)
@@ -0,0 +1,64 @@
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jwbmisc
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Misc util functions of jwb
5
5
  Author-email: Joachim Bargsten <jw@bargsten.org>
6
6
  License: Apache License
@@ -212,7 +212,6 @@ Description-Content-Type: text/markdown
212
212
  License-File: LICENSE
213
213
  Requires-Dist: pyyaml
214
214
  Requires-Dist: more-itertools
215
- Requires-Dist: keyring
216
215
  Provides-Extra: dev
217
216
  Requires-Dist: ruff; extra == "dev"
218
217
  Requires-Dist: pytest>=7; extra == "dev"
@@ -220,6 +219,8 @@ Requires-Dist: setuptools_scm; extra == "dev"
220
219
  Requires-Dist: mypy; extra == "dev"
221
220
  Requires-Dist: pytest-env; extra == "dev"
222
221
  Requires-Dist: pytest-mock; extra == "dev"
222
+ Requires-Dist: keepercommander; extra == "dev"
223
+ Requires-Dist: keyring; extra == "dev"
223
224
  Provides-Extra: build
224
225
  Requires-Dist: build; extra == "build"
225
226
  Requires-Dist: twine; extra == "build"
@@ -0,0 +1,30 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ Makefile
4
+ README.md
5
+ pyproject.toml
6
+ release.sh
7
+ src/jwbmisc/__init__.py
8
+ src/jwbmisc/_version.py
9
+ src/jwbmisc/exec.py
10
+ src/jwbmisc/fs.py
11
+ src/jwbmisc/json.py
12
+ src/jwbmisc/keeper.py
13
+ src/jwbmisc/passwd.py
14
+ src/jwbmisc/string.py
15
+ src/jwbmisc/util.py
16
+ src/jwbmisc.egg-info/PKG-INFO
17
+ src/jwbmisc.egg-info/SOURCES.txt
18
+ src/jwbmisc.egg-info/dependency_links.txt
19
+ src/jwbmisc.egg-info/requires.txt
20
+ src/jwbmisc.egg-info/top_level.txt
21
+ tests/conftest.py
22
+ tests/fzf
23
+ tests/pass
24
+ tests/test_exec.py
25
+ tests/test_fs.py
26
+ tests/test_json.py
27
+ tests/test_keeper.py
28
+ tests/test_passwd.py
29
+ tests/test_string.py
30
+ tests/test_util.py
@@ -1,6 +1,5 @@
1
1
  pyyaml
2
2
  more-itertools
3
- keyring
4
3
 
5
4
  [all]
6
5
  jwbmisc[build,dev,docs]
@@ -21,6 +20,8 @@ setuptools_scm
21
20
  mypy
22
21
  pytest-env
23
22
  pytest-mock
23
+ keepercommander
24
+ keyring
24
25
 
25
26
  [docs]
26
27
  sphinx
@@ -0,0 +1,29 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+
7
+ @pytest.fixture
8
+ def env_var():
9
+ original = os.environ.copy()
10
+
11
+ def _set(**kwargs):
12
+ for k, v in kwargs.items():
13
+ os.environ[k] = v
14
+
15
+ yield _set
16
+ os.environ.clear()
17
+ os.environ.update(original)
18
+
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
+ @pytest.fixture
27
+ def fake_fzf(monkeypatch):
28
+ tests_dir = Path(__file__).parent
29
+ monkeypatch.setenv("PATH", f"{tests_dir}:{os.environ['PATH']}")
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ # Fake fzf script - returns first line of stdin
3
+ read -r line
4
+ echo "$line"
@@ -0,0 +1,8 @@
1
+ #!/bin/bash
2
+ # Fake pass script for testing run_cmd and passwd
3
+ case "$2" in
4
+ "test/secret") echo "password123";;
5
+ "test/multiline") printf "line1\nline2\nline3";;
6
+ "test/missing") echo "Error: not found" >&2; exit 1;;
7
+ *) echo "Error: unknown key $2" >&2; exit 1;;
8
+ esac