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 CHANGED
@@ -1,243 +1,25 @@
1
- import subprocess as sp
2
- import keyring
3
- import random
4
- import string
5
- import re
6
- from collections.abc import Iterable
7
- from typing import Any
8
- import os
9
- import json
10
- from pathlib import Path
11
- import gzip
12
-
13
-
14
- def run_cmd(
15
- cmd,
16
- env=None,
17
- capture=False,
18
- stdin=None,
19
- contains_sensitive_data=False,
20
- timeout=20,
21
- decode=True,
22
- dry_run=False,
23
- ):
24
- if env is None:
25
- env = {}
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.2'
32
- __version_tuple__ = version_tuple = (0, 0, 2)
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.2
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,,
@@ -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,,