python-jack-knife 0.7.7__py3-none-any.whl → 0.8.0__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.
- pjk/components.py +11 -4
- pjk/errors.py +79 -0
- pjk/integrations/rest_pipe.py +330 -0
- pjk/main.py +23 -2
- pjk/man_page.py +3 -2
- pjk/parser.py +162 -14
- pjk/pipes/factory.py +22 -6
- pjk/pipes/grab.py +116 -0
- pjk/pipes/join.py +15 -6
- pjk/pipes/let_reduce.py +203 -50
- pjk/pipes/map.py +11 -10
- pjk/pipes/stats.py +256 -0
- pjk/pipes/where.py +7 -4
- pjk/progress.py +36 -5
- pjk/sinks/graph_hist.py +4 -5
- pjk/sinks/stdout.py +90 -2
- pjk/version.py +1 -1
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/METADATA +1 -1
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/RECORD +23 -19
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/WHEEL +1 -1
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/entry_points.txt +0 -0
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {python_jack_knife-0.7.7.dist-info → python_jack_knife-0.8.0.dist-info}/top_level.txt +0 -0
pjk/components.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Copyright 2024 Mike Schultz
|
|
3
3
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import Any, Optional, List
|
|
5
|
+
from typing import Any, Iterable, Optional, List
|
|
6
6
|
from pjk.usage import Usage, NoBindUsage, ParsedToken
|
|
7
7
|
|
|
8
8
|
# mixin
|
|
@@ -19,9 +19,16 @@ class KeyedSource(ABC):
|
|
|
19
19
|
"""Return the record associated with the given key, or None."""
|
|
20
20
|
pass
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
def lookup_item(self, left_rec):
|
|
23
|
+
"""Return ``(key, record)`` for consumers that need match identity."""
|
|
24
|
+
record = self.lookup(left_rec)
|
|
25
|
+
if record is None:
|
|
26
|
+
return None
|
|
27
|
+
return id(record), record
|
|
28
|
+
|
|
29
|
+
def keyed_items(self) -> Iterable[tuple[Any, dict]]:
|
|
30
|
+
"""Iterate ``(key, record)`` pairs without changing lookup state."""
|
|
31
|
+
return ((id(record), record) for record in self)
|
|
25
32
|
|
|
26
33
|
def deep_copy(self):
|
|
27
34
|
return None
|
pjk/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 Mike Schultz
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
CLI_PROBLEM_HEADER = "You've got a problem here."
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _quote_token(token: str) -> str:
|
|
14
|
+
if token.startswith('[') or token.startswith('{'):
|
|
15
|
+
return '"' + token + '"'
|
|
16
|
+
return token
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_cli_command(tokens: List[str], token_no: int = 0, marker: str = '^') -> List[str]:
|
|
20
|
+
"""Return command line + caret underline for CLI errors."""
|
|
21
|
+
if not tokens:
|
|
22
|
+
return []
|
|
23
|
+
token_copies = [_quote_token(t) for t in tokens]
|
|
24
|
+
lines = ['pjk ' + ' '.join(token_copies)]
|
|
25
|
+
offset = 4 + sum(len(t) + 1 for t in token_copies[:token_no])
|
|
26
|
+
lines.append(' ' * offset + marker * len(token_copies[token_no]))
|
|
27
|
+
return lines
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_token_index(tokens: List[str], token: str) -> int:
|
|
31
|
+
"""Locate the failing pipe token in argv tokens."""
|
|
32
|
+
for i, t in enumerate(tokens):
|
|
33
|
+
if t == token:
|
|
34
|
+
return i
|
|
35
|
+
for i, t in enumerate(tokens):
|
|
36
|
+
if token in t or t in token:
|
|
37
|
+
return i
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RecordProcessingError(Exception):
|
|
42
|
+
"""Per-record pipeline failure with CLI-friendly formatting."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
token: str,
|
|
48
|
+
record: dict,
|
|
49
|
+
cause: BaseException,
|
|
50
|
+
message: Optional[str] = None,
|
|
51
|
+
tokens: Optional[List[str]] = None,
|
|
52
|
+
token_no: int = 0,
|
|
53
|
+
):
|
|
54
|
+
super().__init__(message or f"Error in {token}")
|
|
55
|
+
self.token = token
|
|
56
|
+
self.record = record
|
|
57
|
+
self.cause = cause
|
|
58
|
+
self.message = message or f"Error in {token}"
|
|
59
|
+
self.tokens = tokens
|
|
60
|
+
self.token_no = token_no
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
lines = []
|
|
64
|
+
if self.tokens:
|
|
65
|
+
lines.extend(format_cli_command(self.tokens, self.token_no))
|
|
66
|
+
lines.append(CLI_PROBLEM_HEADER)
|
|
67
|
+
lines.append('')
|
|
68
|
+
lines.append(str(self.cause))
|
|
69
|
+
lines.append('')
|
|
70
|
+
lines.append('On this record:')
|
|
71
|
+
lines.append(
|
|
72
|
+
yaml.dump(
|
|
73
|
+
self.record,
|
|
74
|
+
default_flow_style=False,
|
|
75
|
+
sort_keys=False,
|
|
76
|
+
allow_unicode=True,
|
|
77
|
+
).rstrip()
|
|
78
|
+
)
|
|
79
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 Mike Schultz
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Generic REST integration pipe (1 record in → 1 record out).
|
|
6
|
+
|
|
7
|
+
Configured via configs.yaml as RestPipe-<instance>. Path placeholders
|
|
8
|
+
``{field}`` are filled from the input record; JSON body is built from the
|
|
9
|
+
record (or a field subset). Response JSON object is yielded as the output
|
|
10
|
+
record.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
from urllib.parse import urljoin
|
|
20
|
+
|
|
21
|
+
from pjk.common import Integration
|
|
22
|
+
from pjk.components import DeepCopyPipe
|
|
23
|
+
from pjk.errors import RecordProcessingError
|
|
24
|
+
from pjk.progress import papi
|
|
25
|
+
from pjk.usage import ParsedToken, Usage, configs
|
|
26
|
+
|
|
27
|
+
PATH_PARAM_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
28
|
+
|
|
29
|
+
BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
|
30
|
+
|
|
31
|
+
# Refresh a bit before expires_in to avoid edge-of-expiry 401s.
|
|
32
|
+
_TOKEN_EXPIRY_SKEW_SEC = 30
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_path(path: str, record: dict) -> str:
|
|
36
|
+
missing = []
|
|
37
|
+
|
|
38
|
+
def repl(match: re.Match) -> str:
|
|
39
|
+
name = match.group(1)
|
|
40
|
+
if name not in record or record[name] is None:
|
|
41
|
+
missing.append(name)
|
|
42
|
+
return match.group(0)
|
|
43
|
+
return str(record[name])
|
|
44
|
+
|
|
45
|
+
out = PATH_PARAM_RE.sub(repl, path)
|
|
46
|
+
if missing:
|
|
47
|
+
raise ValueError(f"missing path field(s): {', '.join(missing)}")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_url(base_url: str, path: str) -> str:
|
|
52
|
+
base = base_url if base_url.endswith("/") else base_url + "/"
|
|
53
|
+
rel = path.lstrip("/")
|
|
54
|
+
return urljoin(base, rel)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RestPipe(DeepCopyPipe, Integration):
|
|
58
|
+
@classmethod
|
|
59
|
+
def usage(cls):
|
|
60
|
+
u = Usage(
|
|
61
|
+
name="rest",
|
|
62
|
+
desc=(
|
|
63
|
+
"Generic REST integration: one HTTP request per input record.\n"
|
|
64
|
+
"Yields one output record (JSON response body).\n"
|
|
65
|
+
"Configure RestPipe-<instance> in configs.yaml with base_url, "
|
|
66
|
+
"method, and path.\n"
|
|
67
|
+
"Path placeholders {field} are filled from the input record."
|
|
68
|
+
),
|
|
69
|
+
component_class=cls,
|
|
70
|
+
)
|
|
71
|
+
u.def_arg(
|
|
72
|
+
name="instance",
|
|
73
|
+
usage=(
|
|
74
|
+
"configs.yaml entry RestPipe-<instance> must define "
|
|
75
|
+
"base_url, method, path (and auth as needed)"
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
u.def_config_tuples(
|
|
79
|
+
[
|
|
80
|
+
("base_url", str, None),
|
|
81
|
+
("method", str, None),
|
|
82
|
+
("path", str, None),
|
|
83
|
+
("auth", str, "none", True), # none|bearer|basic|client_credentials
|
|
84
|
+
("token", str, None, True),
|
|
85
|
+
("token_env", str, None, True),
|
|
86
|
+
("username", str, None, True),
|
|
87
|
+
("password", str, None, True),
|
|
88
|
+
("auth_url", str, None, True),
|
|
89
|
+
("auth_client_id", str, None, True),
|
|
90
|
+
("auth_client_secret", str, None, True),
|
|
91
|
+
("timeout", float, 30.0, True),
|
|
92
|
+
("body", str, "record", True), # record | none
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
u.def_example(
|
|
96
|
+
expr_tokens=[
|
|
97
|
+
"{id: '1', amount: 10}",
|
|
98
|
+
"rest:demo",
|
|
99
|
+
],
|
|
100
|
+
expect=None,
|
|
101
|
+
)
|
|
102
|
+
return u
|
|
103
|
+
|
|
104
|
+
def __init__(self, ptok: ParsedToken, usage: Usage, root=None):
|
|
105
|
+
super().__init__(ptok, usage, root)
|
|
106
|
+
self.token = ptok.whole_token
|
|
107
|
+
self.usage_obj = usage
|
|
108
|
+
|
|
109
|
+
self.base_url = usage.get_config("base_url")
|
|
110
|
+
self.method = (usage.get_config("method") or "").upper()
|
|
111
|
+
self.path = usage.get_config("path")
|
|
112
|
+
self.auth = (usage.get_config("auth") or "none").lower()
|
|
113
|
+
self.token_value = usage.get_config("token")
|
|
114
|
+
self.token_env = usage.get_config("token_env")
|
|
115
|
+
self.username = usage.get_config("username")
|
|
116
|
+
self.password = usage.get_config("password")
|
|
117
|
+
self.auth_url = usage.get_config("auth_url")
|
|
118
|
+
self.auth_client_id = usage.get_config("auth_client_id")
|
|
119
|
+
self.auth_client_secret = usage.get_config("auth_client_secret")
|
|
120
|
+
self.timeout = usage.get_config("timeout") or 30.0
|
|
121
|
+
self.body_mode = (usage.get_config("body") or "record").lower()
|
|
122
|
+
|
|
123
|
+
entry = self._resolved_entry()
|
|
124
|
+
raw_fields = entry.get("body_fields") if entry else None
|
|
125
|
+
if isinstance(raw_fields, list):
|
|
126
|
+
self.body_fields: Optional[List[str]] = [str(x) for x in raw_fields]
|
|
127
|
+
elif isinstance(raw_fields, str) and raw_fields.strip():
|
|
128
|
+
self.body_fields = [p.strip() for p in raw_fields.split(",") if p.strip()]
|
|
129
|
+
else:
|
|
130
|
+
self.body_fields = None
|
|
131
|
+
|
|
132
|
+
raw_headers = entry.get("headers") if entry else None
|
|
133
|
+
self.extra_headers: Dict[str, str] = (
|
|
134
|
+
{str(k): str(v) for k, v in raw_headers.items()}
|
|
135
|
+
if isinstance(raw_headers, dict)
|
|
136
|
+
else {}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if self.method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}:
|
|
140
|
+
raise ValueError(f"rest: unsupported method {self.method!r}")
|
|
141
|
+
|
|
142
|
+
# Cached OAuth2 access token for auth=client_credentials
|
|
143
|
+
self._access_token: Optional[str] = None
|
|
144
|
+
self._access_token_expires_at: float = 0.0
|
|
145
|
+
|
|
146
|
+
self.recs_in = papi.get_counter(self, "recs_in", display=False)
|
|
147
|
+
self.recs_out = papi.get_percentage_counter(self, "recs_out", self.recs_in)
|
|
148
|
+
|
|
149
|
+
def _resolved_entry(self) -> dict:
|
|
150
|
+
configs._ensure_loaded()
|
|
151
|
+
instance = self.usage_obj.get_arg("instance")
|
|
152
|
+
key = f"{type(self).__name__}-{instance}"
|
|
153
|
+
return configs._resolve_entry(key) or {}
|
|
154
|
+
|
|
155
|
+
def reset(self):
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def close(self):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
def _fetch_client_credentials_token(self) -> Tuple[str, float]:
|
|
162
|
+
"""
|
|
163
|
+
OAuth2 client_credentials against auth_url (OIDC/Keycloak-style).
|
|
164
|
+
Returns (access_token, expires_at_epoch).
|
|
165
|
+
"""
|
|
166
|
+
import requests
|
|
167
|
+
|
|
168
|
+
if not self.auth_url:
|
|
169
|
+
raise ValueError("rest auth=client_credentials requires config 'auth_url'")
|
|
170
|
+
if not self.auth_client_id:
|
|
171
|
+
raise ValueError("rest auth=client_credentials requires config 'auth_client_id'")
|
|
172
|
+
if not self.auth_client_secret:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"rest auth=client_credentials requires config 'auth_client_secret'"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
response = requests.post(
|
|
178
|
+
self.auth_url,
|
|
179
|
+
data={
|
|
180
|
+
"grant_type": "client_credentials",
|
|
181
|
+
"client_id": self.auth_client_id,
|
|
182
|
+
"client_secret": self.auth_client_secret,
|
|
183
|
+
},
|
|
184
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
185
|
+
timeout=self.timeout,
|
|
186
|
+
)
|
|
187
|
+
if response.status_code >= 400:
|
|
188
|
+
snippet = (response.text or "")[:500]
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
f"HTTP {response.status_code} fetching token from {self.auth_url}: {snippet}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
payload = response.json()
|
|
194
|
+
access_token = payload.get("access_token")
|
|
195
|
+
if not access_token:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"token response from {self.auth_url} missing access_token"
|
|
198
|
+
)
|
|
199
|
+
expires_in = float(payload.get("expires_in") or 300)
|
|
200
|
+
expires_at = time.time() + max(expires_in - _TOKEN_EXPIRY_SKEW_SEC, 1)
|
|
201
|
+
return access_token, expires_at
|
|
202
|
+
|
|
203
|
+
def _bearer_token(self) -> str:
|
|
204
|
+
if self.auth == "bearer":
|
|
205
|
+
token = self.token_value
|
|
206
|
+
if not token and self.token_env:
|
|
207
|
+
token = os.environ.get(self.token_env)
|
|
208
|
+
if not token:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
"rest auth=bearer requires config 'token' or 'token_env' "
|
|
211
|
+
f"(env {self.token_env!r} empty/missing)"
|
|
212
|
+
)
|
|
213
|
+
return token
|
|
214
|
+
|
|
215
|
+
if self.auth == "client_credentials":
|
|
216
|
+
now = time.time()
|
|
217
|
+
if self._access_token and now < self._access_token_expires_at:
|
|
218
|
+
return self._access_token
|
|
219
|
+
token, expires_at = self._fetch_client_credentials_token()
|
|
220
|
+
self._access_token = token
|
|
221
|
+
self._access_token_expires_at = expires_at
|
|
222
|
+
return token
|
|
223
|
+
|
|
224
|
+
raise ValueError(f"rest: no bearer token for auth={self.auth!r}")
|
|
225
|
+
|
|
226
|
+
def _auth_headers_and_auth(self):
|
|
227
|
+
import requests
|
|
228
|
+
|
|
229
|
+
headers = dict(self.extra_headers)
|
|
230
|
+
auth = None
|
|
231
|
+
|
|
232
|
+
if self.auth in ("none", "", None):
|
|
233
|
+
return headers, auth
|
|
234
|
+
|
|
235
|
+
if self.auth in ("bearer", "client_credentials"):
|
|
236
|
+
headers["Authorization"] = f"Bearer {self._bearer_token()}"
|
|
237
|
+
return headers, auth
|
|
238
|
+
|
|
239
|
+
if self.auth == "basic":
|
|
240
|
+
if not self.username:
|
|
241
|
+
raise ValueError("rest auth=basic requires config 'username'")
|
|
242
|
+
auth = requests.auth.HTTPBasicAuth(self.username, self.password or "")
|
|
243
|
+
return headers, auth
|
|
244
|
+
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"rest: unsupported auth {self.auth!r} "
|
|
247
|
+
"(use none|bearer|basic|client_credentials)"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _build_body(self, record: dict) -> Optional[dict]:
|
|
251
|
+
if self.method not in BODY_METHODS:
|
|
252
|
+
return None
|
|
253
|
+
if self.body_mode == "none":
|
|
254
|
+
return None
|
|
255
|
+
if self.body_fields is not None:
|
|
256
|
+
return {k: record[k] for k in self.body_fields if k in record}
|
|
257
|
+
# body: record
|
|
258
|
+
return dict(record)
|
|
259
|
+
|
|
260
|
+
def _response_record(self, response) -> dict:
|
|
261
|
+
content_type = response.headers.get("Content-Type", "") or ""
|
|
262
|
+
text = response.text
|
|
263
|
+
try:
|
|
264
|
+
obj = response.json()
|
|
265
|
+
except Exception:
|
|
266
|
+
return {
|
|
267
|
+
"status_code": response.status_code,
|
|
268
|
+
"content_type": content_type.split(";")[0].strip(),
|
|
269
|
+
"body": text,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if isinstance(obj, dict):
|
|
273
|
+
return obj
|
|
274
|
+
if isinstance(obj, list):
|
|
275
|
+
return {"items": obj}
|
|
276
|
+
return {"value": obj}
|
|
277
|
+
|
|
278
|
+
def __iter__(self):
|
|
279
|
+
import requests
|
|
280
|
+
|
|
281
|
+
for record in self.left:
|
|
282
|
+
self.recs_in.increment()
|
|
283
|
+
try:
|
|
284
|
+
path = _format_path(self.path, record)
|
|
285
|
+
url = _build_url(self.base_url, path)
|
|
286
|
+
headers, auth = self._auth_headers_and_auth()
|
|
287
|
+
body = self._build_body(record)
|
|
288
|
+
|
|
289
|
+
kwargs: Dict[str, Any] = {
|
|
290
|
+
"method": self.method,
|
|
291
|
+
"url": url,
|
|
292
|
+
"headers": headers,
|
|
293
|
+
"auth": auth,
|
|
294
|
+
"timeout": self.timeout,
|
|
295
|
+
"allow_redirects": True,
|
|
296
|
+
}
|
|
297
|
+
if body is not None:
|
|
298
|
+
kwargs["json"] = body
|
|
299
|
+
|
|
300
|
+
response = requests.request(**kwargs)
|
|
301
|
+
# One refresh+retry on 401 when using client_credentials.
|
|
302
|
+
if (
|
|
303
|
+
response.status_code == 401
|
|
304
|
+
and self.auth == "client_credentials"
|
|
305
|
+
):
|
|
306
|
+
self._access_token = None
|
|
307
|
+
self._access_token_expires_at = 0.0
|
|
308
|
+
headers, auth = self._auth_headers_and_auth()
|
|
309
|
+
kwargs["headers"] = headers
|
|
310
|
+
kwargs["auth"] = auth
|
|
311
|
+
response = requests.request(**kwargs)
|
|
312
|
+
|
|
313
|
+
if response.status_code >= 400:
|
|
314
|
+
snippet = (response.text or "")[:500]
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
f"HTTP {response.status_code} for {self.method} {url}: {snippet}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
out = self._response_record(response)
|
|
320
|
+
except RecordProcessingError:
|
|
321
|
+
raise
|
|
322
|
+
except Exception as e:
|
|
323
|
+
raise RecordProcessingError(
|
|
324
|
+
token=self.token,
|
|
325
|
+
record=record if isinstance(record, dict) else {"value": record},
|
|
326
|
+
cause=e,
|
|
327
|
+
) from e
|
|
328
|
+
|
|
329
|
+
self.recs_out.increment()
|
|
330
|
+
yield out
|
pjk/main.py
CHANGED
|
@@ -8,6 +8,7 @@ import shlex
|
|
|
8
8
|
from typing import List, Dict
|
|
9
9
|
from pjk.parser import ExpressionParser
|
|
10
10
|
from pjk.usage import UsageError
|
|
11
|
+
from pjk.errors import RecordProcessingError, find_token_index
|
|
11
12
|
from pjk.log import init as init_logging
|
|
12
13
|
import traceback
|
|
13
14
|
import concurrent.futures
|
|
@@ -37,6 +38,14 @@ def execute_threaded(sinks, stop_progress=None):
|
|
|
37
38
|
executor.shutdown(wait=False, cancel_futures=True)
|
|
38
39
|
os._exit(130)
|
|
39
40
|
|
|
41
|
+
except RecordProcessingError:
|
|
42
|
+
if stop_progress:
|
|
43
|
+
try: stop_progress()
|
|
44
|
+
except Exception: pass
|
|
45
|
+
for f in futures:
|
|
46
|
+
f.cancel()
|
|
47
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
48
|
+
raise
|
|
40
49
|
except Exception as e:
|
|
41
50
|
if stop_progress:
|
|
42
51
|
try: stop_progress()
|
|
@@ -116,6 +125,7 @@ def execute_tokens(tokens: List[str]):
|
|
|
116
125
|
parser = ExpressionParser(registry)
|
|
117
126
|
|
|
118
127
|
display = None
|
|
128
|
+
display_cleared = False
|
|
119
129
|
try:
|
|
120
130
|
sink = parser.parse(tokens)
|
|
121
131
|
write_history(sys.argv[1:]) # now that it's parsed sucessfully
|
|
@@ -133,17 +143,28 @@ def execute_tokens(tokens: List[str]):
|
|
|
133
143
|
|
|
134
144
|
if len(sinks) > 1:
|
|
135
145
|
# pass a stopper so we halt the UI before tracebacks / shutdown
|
|
136
|
-
execute_threaded(
|
|
146
|
+
execute_threaded(
|
|
147
|
+
sinks,
|
|
148
|
+
stop_progress=(lambda: display.stop(clear=True) if display else None),
|
|
149
|
+
)
|
|
137
150
|
else:
|
|
138
151
|
sink.drain()
|
|
139
152
|
|
|
140
153
|
except UsageError as e:
|
|
141
154
|
print(e, file=sys.stderr)
|
|
142
155
|
sys.exit(2)
|
|
156
|
+
except RecordProcessingError as e:
|
|
157
|
+
if display:
|
|
158
|
+
display.stop(clear=True, timeout=0.5)
|
|
159
|
+
display_cleared = True
|
|
160
|
+
e.tokens = tokens
|
|
161
|
+
e.token_no = find_token_index(tokens, e.token)
|
|
162
|
+
print(e, file=sys.stderr)
|
|
163
|
+
sys.exit(2)
|
|
143
164
|
except KeyboardInterrupt:
|
|
144
165
|
pass
|
|
145
166
|
finally:
|
|
146
|
-
if display:
|
|
167
|
+
if display and not display_cleared:
|
|
147
168
|
# short join so Ctrl-C is immediate
|
|
148
169
|
try: display.stop(timeout=0.1)
|
|
149
170
|
except Exception: pass
|
pjk/man_page.py
CHANGED
|
@@ -104,8 +104,9 @@ def display_configs():
|
|
|
104
104
|
print(' ', highlight(name, 'bold', name))
|
|
105
105
|
print('=========================================')
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
for secret_key in ('password', 'token', 'auth_client_secret'):
|
|
108
|
+
if secret_key in body_dict:
|
|
109
|
+
body_dict[secret_key] = '*************'
|
|
109
110
|
|
|
110
111
|
try:
|
|
111
112
|
yaml.dump(
|