jwbmisc 0.0.2__py3-none-any.whl → 0.0.3__py3-none-any.whl
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/__init__.py +25 -243
- jwbmisc/_version.py +2 -2
- jwbmisc/exec.py +50 -0
- jwbmisc/fs.py +28 -0
- jwbmisc/json.py +46 -0
- jwbmisc/keeper.py +137 -0
- jwbmisc/passwd.py +75 -0
- jwbmisc/string.py +22 -0
- jwbmisc/util.py +64 -0
- {jwbmisc-0.0.2.dist-info → jwbmisc-0.0.3.dist-info}/METADATA +3 -2
- jwbmisc-0.0.3.dist-info/RECORD +14 -0
- jwbmisc-0.0.2.dist-info/RECORD +0 -7
- {jwbmisc-0.0.2.dist-info → jwbmisc-0.0.3.dist-info}/WHEEL +0 -0
- {jwbmisc-0.0.2.dist-info → jwbmisc-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {jwbmisc-0.0.2.dist-info → jwbmisc-0.0.3.dist-info}/top_level.txt +0 -0
jwbmisc/__init__.py
CHANGED
|
@@ -1,243 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
env = {**os.environ, **env}
|
|
27
|
-
env.pop("__PYVENV_LAUNCHER__", None)
|
|
28
|
-
|
|
29
|
-
if stdin is not None:
|
|
30
|
-
stdin = stdin.encode("utf-8")
|
|
31
|
-
|
|
32
|
-
cmd = [str(v) for v in cmd]
|
|
33
|
-
|
|
34
|
-
if dry_run:
|
|
35
|
-
print(cmd)
|
|
36
|
-
if capture:
|
|
37
|
-
return ("", "")
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
try:
|
|
41
|
-
res = sp.run(
|
|
42
|
-
cmd,
|
|
43
|
-
capture_output=capture,
|
|
44
|
-
env=env,
|
|
45
|
-
check=True,
|
|
46
|
-
timeout=timeout,
|
|
47
|
-
input=stdin,
|
|
48
|
-
)
|
|
49
|
-
except sp.CalledProcessError as ex:
|
|
50
|
-
redacted_bytes = "<redacted>".encode("utf-8")
|
|
51
|
-
out = redacted_bytes if contains_sensitive_data else ex.output
|
|
52
|
-
err = redacted_bytes if contains_sensitive_data else ex.stderr
|
|
53
|
-
raise sp.CalledProcessError(ex.returncode, ex.cmd, out, err) from None
|
|
54
|
-
|
|
55
|
-
if not capture:
|
|
56
|
-
return None
|
|
57
|
-
if decode:
|
|
58
|
-
return (res.stdout.decode("utf-8"), res.stderr.decode("utf-8"))
|
|
59
|
-
return (res.stdout, res.stderr)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def split_host(host: str) -> tuple[str | None, int | None]:
|
|
63
|
-
if not host:
|
|
64
|
-
return (None, None)
|
|
65
|
-
res = host.split(":", 1)
|
|
66
|
-
if len(res) == 1:
|
|
67
|
-
return (res[0], None)
|
|
68
|
-
return (res[0], int(res[1]))
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def resilient_loads(data):
|
|
72
|
-
if not data:
|
|
73
|
-
return None
|
|
74
|
-
try:
|
|
75
|
-
return json.loads(data)
|
|
76
|
-
except Exception:
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def goo(
|
|
81
|
-
d: dict[str, Any],
|
|
82
|
-
*keys: str | int,
|
|
83
|
-
default: Any | None = None,
|
|
84
|
-
raise_on_default: bool = False,
|
|
85
|
-
):
|
|
86
|
-
path = ".".join(str(k) for k in keys)
|
|
87
|
-
parts = path.split(".")
|
|
88
|
-
|
|
89
|
-
res = d
|
|
90
|
-
for p in parts:
|
|
91
|
-
if res is None:
|
|
92
|
-
if raise_on_default:
|
|
93
|
-
raise ValueError("'{path}' does not exist")
|
|
94
|
-
return default
|
|
95
|
-
if isinstance(res, (list, set, tuple)):
|
|
96
|
-
res = res[int(p)]
|
|
97
|
-
else:
|
|
98
|
-
res = res.get(p)
|
|
99
|
-
if res is None:
|
|
100
|
-
if raise_on_default:
|
|
101
|
-
raise ValueError("'{path}' does not exist")
|
|
102
|
-
return default
|
|
103
|
-
return res
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def fzf(entries: Iterable[str]):
|
|
107
|
-
process = sp.Popen(
|
|
108
|
-
["fzf", "+m"],
|
|
109
|
-
stdout=sp.PIPE,
|
|
110
|
-
stdin=sp.PIPE,
|
|
111
|
-
encoding="utf-8",
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
stdout, _ = process.communicate(input="\n".join(entries) + "\n")
|
|
115
|
-
return stdout.strip()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def get_pass(*pass_keys: str):
|
|
119
|
-
if not pass_keys:
|
|
120
|
-
raise ValueError("no pass keys supplied")
|
|
121
|
-
|
|
122
|
-
for pass_key in pass_keys:
|
|
123
|
-
if pass_key.startswith("pass://"):
|
|
124
|
-
k = pass_key.removeprefix("pass://")
|
|
125
|
-
lnum = 1
|
|
126
|
-
if "?" in k:
|
|
127
|
-
k, lnum = k.rsplit("?", 1)
|
|
128
|
-
return _call_unix_pass(k, int(lnum))
|
|
129
|
-
|
|
130
|
-
if pass_key.startswith("env://"):
|
|
131
|
-
env_var = pass_key.removeprefix("env://").replace("/", "__")
|
|
132
|
-
if env_var not in os.environ:
|
|
133
|
-
raise KeyError(f"{env_var} (derived from {pass_key}) is not in the env")
|
|
134
|
-
return os.environ[env_var]
|
|
135
|
-
|
|
136
|
-
if pass_key.startswith("file://"):
|
|
137
|
-
f = Path(pass_key.removeprefix("file://"))
|
|
138
|
-
if not f.exists() or f.is_dir():
|
|
139
|
-
raise KeyError(f"{f} (derived from {pass_key}) does not exist or is a dir")
|
|
140
|
-
return f.read_text().strip()
|
|
141
|
-
|
|
142
|
-
if pass_key.startswith("keyring://"):
|
|
143
|
-
args = pass_key.removeprefix("keyring://").split("/")
|
|
144
|
-
pw = keyring.get_password(*args)
|
|
145
|
-
if pw is None:
|
|
146
|
-
raise KeyError(f"could not find a password for {pass_key}")
|
|
147
|
-
return pw
|
|
148
|
-
|
|
149
|
-
raise KeyError(f"Could not acquire password from one of {pass_keys}")
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _call_unix_pass(key, lnum=1):
|
|
153
|
-
proc = sp.Popen(["pass", "show", key], stdout=sp.PIPE, encoding="utf-8")
|
|
154
|
-
value, _ = proc.communicate()
|
|
155
|
-
|
|
156
|
-
if lnum is None or lnum == 0:
|
|
157
|
-
return value.strip()
|
|
158
|
-
lines = value.splitlines()
|
|
159
|
-
|
|
160
|
-
try:
|
|
161
|
-
if isinstance(lnum, list):
|
|
162
|
-
pw = [lines[ln - 1].strip() for ln in lnum]
|
|
163
|
-
pw = lines[lnum - 1].strip()
|
|
164
|
-
except IndexError:
|
|
165
|
-
raise KeyError(f"could not not retrieve lines {lnum} for {key}")
|
|
166
|
-
|
|
167
|
-
return pw
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def jinja_replace(s, config, relaxed=False, delim=("{{", "}}")):
|
|
171
|
-
"""Jinja for poor people. A very simple
|
|
172
|
-
function to replace variables in text using `{{variable}}` syntax.
|
|
173
|
-
|
|
174
|
-
:param s: the template string/text
|
|
175
|
-
:param config: a dict of variable -> replacement mapping
|
|
176
|
-
:param relaxed: Don't raise a KeyError if a variable is not in the config dict.
|
|
177
|
-
:param delim: Change the delimiters to something else.
|
|
178
|
-
"""
|
|
179
|
-
|
|
180
|
-
def handle_match(m):
|
|
181
|
-
k = m.group(1)
|
|
182
|
-
if k in config:
|
|
183
|
-
return config[k]
|
|
184
|
-
if relaxed:
|
|
185
|
-
return m.group(0)
|
|
186
|
-
raise KeyError(f"{k} is not in the supplied replacement variables")
|
|
187
|
-
|
|
188
|
-
return re.sub(re.escape(delim[0]) + r"\s*(\w+)\s*" + re.escape(delim[1]), handle_match, s)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def randomsuffix(length):
|
|
192
|
-
letters = string.ascii_lowercase
|
|
193
|
-
return "".join(random.choice(letters) for _ in range(length))
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def confirm(question, default="n"):
|
|
197
|
-
prompt = f"{question} (y/n)"
|
|
198
|
-
if default is not None:
|
|
199
|
-
prompt += f" [{default}]"
|
|
200
|
-
answer = input(prompt).strip().lower()
|
|
201
|
-
return answer.startswith("y")
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def find_root(start, req):
|
|
205
|
-
p = Path(start).absolute()
|
|
206
|
-
if p.is_file():
|
|
207
|
-
p = p.parent
|
|
208
|
-
|
|
209
|
-
while p.parent != p:
|
|
210
|
-
files = {f.name for f in p.iterdir()}
|
|
211
|
-
if req <= files:
|
|
212
|
-
return p
|
|
213
|
-
p = p.parent
|
|
214
|
-
return None
|
|
215
|
-
|
|
216
|
-
def jsonc_loads(data: str):
|
|
217
|
-
data = re.sub(r"//.*$", "", data, flags=re.MULTILINE)
|
|
218
|
-
data = re.sub(r"/\*.*?\*/", "", data, flags=re.DOTALL)
|
|
219
|
-
return json.loads(data)
|
|
220
|
-
|
|
221
|
-
def jsonc_read(f: str | Path):
|
|
222
|
-
f = Path(f)
|
|
223
|
-
open_fn = gzip.open if f.suffix.lower() == ".gz" else open
|
|
224
|
-
with open_fn(f, "rt", encoding="utf-8") as fd:
|
|
225
|
-
return jsonc_loads(fd.read())
|
|
226
|
-
|
|
227
|
-
def ndjson_read(f: str | Path):
|
|
228
|
-
f = Path(f)
|
|
229
|
-
open_fn = gzip.open if f.suffix.lower() == ".gz" else open
|
|
230
|
-
with open_fn(f, "rt", encoding="utf-8") as fd:
|
|
231
|
-
for line in fd:
|
|
232
|
-
line = line.strip()
|
|
233
|
-
if line and not line.startswith("#"):
|
|
234
|
-
yield json.loads(line)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def ndjson_write(data: list[Any], f: str | Path):
|
|
238
|
-
f = Path(f)
|
|
239
|
-
open_fn = gzip.open if f.suffix.lower() == ".gz" else open
|
|
240
|
-
with open_fn(f, "wb") as fd:
|
|
241
|
-
for record in data:
|
|
242
|
-
blob = (json.dumps(record) + "\n").encode("utf-8")
|
|
243
|
-
fd.write(blob)
|
|
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
|
+
]
|
jwbmisc/_version.py
CHANGED
|
@@ -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.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 3)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
jwbmisc/exec.py
ADDED
|
@@ -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)
|
jwbmisc/fs.py
ADDED
|
@@ -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
|
jwbmisc/json.py
ADDED
|
@@ -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
|
jwbmisc/keeper.py
ADDED
|
@@ -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
|
jwbmisc/passwd.py
ADDED
|
@@ -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)
|
jwbmisc/string.py
ADDED
|
@@ -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)
|
jwbmisc/util.py
ADDED
|
@@ -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.
|
|
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,14 @@
|
|
|
1
|
+
jwbmisc/__init__.py,sha256=VT_f1aG3ufoT7mYV1Ho0ocNtydMTIPbmcvOANwQE-CQ,551
|
|
2
|
+
jwbmisc/_version.py,sha256=pBZsQt6tlL02W-ri--X_4JCubpAK7jjCSnOmUp_isjc,704
|
|
3
|
+
jwbmisc/exec.py,sha256=9g1Jc7iDkBj1Y-dn5VhnwH1JqvWSbrFe6Mmvnf-iqag,1177
|
|
4
|
+
jwbmisc/fs.py,sha256=Vf28qbOnBeHEbXNMUZjOQXtMWBurjkzD2KmfV2gJQXM,599
|
|
5
|
+
jwbmisc/json.py,sha256=h3CBDNNZjcTDxydViyydPsQufXQLuxqP24wBBAT2nDs,1198
|
|
6
|
+
jwbmisc/keeper.py,sha256=nOOfzoAPF7Uy3TuMPPdBdq9DvwNanyrQp6lVFI66LnU,4317
|
|
7
|
+
jwbmisc/passwd.py,sha256=ZcOIT_RrwDR3uZgrcWASRLz8D6gf6iAHOEdyfwTuX4I,2520
|
|
8
|
+
jwbmisc/string.py,sha256=pUNAMaP5531mBy3mrp9GCZ3QRPy6MQwegt5yzcWP1wQ,795
|
|
9
|
+
jwbmisc/util.py,sha256=XCF5LEOLjI4IsYP-FF2ytbzo4J0rlCNJzrdNrtNWSGM,1568
|
|
10
|
+
jwbmisc-0.0.3.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
11
|
+
jwbmisc-0.0.3.dist-info/METADATA,sha256=U6WTkWiesNELCpjGh0Myaj-Uzd6To1qw7K28--fAWkU,14507
|
|
12
|
+
jwbmisc-0.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
13
|
+
jwbmisc-0.0.3.dist-info/top_level.txt,sha256=FqEYs8zdG3iGOJmC6cutDXfGQUNptzfYeKsaG43y1HE,8
|
|
14
|
+
jwbmisc-0.0.3.dist-info/RECORD,,
|
jwbmisc-0.0.2.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
jwbmisc/__init__.py,sha256=Tgh1Dm7B00JknbD4frFd-rAFy2E3NuT-9eZub9ck-Lg,6702
|
|
2
|
-
jwbmisc/_version.py,sha256=huLsL1iGeXWQKZ8bjwDdIWC7JOkj3wnzBh-HFMZl1PY,704
|
|
3
|
-
jwbmisc-0.0.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
4
|
-
jwbmisc-0.0.2.dist-info/METADATA,sha256=uJnh1a-j_XzPaE3zzPXBeMye2VKp2EHEaZHv25GWtdo,14444
|
|
5
|
-
jwbmisc-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
-
jwbmisc-0.0.2.dist-info/top_level.txt,sha256=FqEYs8zdG3iGOJmC6cutDXfGQUNptzfYeKsaG43y1HE,8
|
|
7
|
-
jwbmisc-0.0.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|