python-jack-knife 0.7.7__tar.gz → 0.8.0__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 (98) hide show
  1. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/PKG-INFO +1 -1
  2. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/components.py +11 -4
  3. python_jack_knife-0.8.0/src/pjk/errors.py +79 -0
  4. python_jack_knife-0.8.0/src/pjk/integrations/rest_pipe.py +330 -0
  5. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/main.py +23 -2
  6. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/man_page.py +3 -2
  7. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/parser.py +162 -14
  8. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/factory.py +22 -6
  9. python_jack_knife-0.8.0/src/pjk/pipes/grab.py +116 -0
  10. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/join.py +15 -6
  11. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/let_reduce.py +203 -50
  12. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/map.py +11 -10
  13. python_jack_knife-0.8.0/src/pjk/pipes/stats.py +256 -0
  14. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/where.py +7 -4
  15. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/progress.py +36 -5
  16. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_hist.py +4 -5
  17. python_jack_knife-0.8.0/src/pjk/sinks/stdout.py +166 -0
  18. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/version.py +1 -1
  19. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/PKG-INFO +1 -1
  20. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/SOURCES.txt +4 -0
  21. python_jack_knife-0.7.7/src/pjk/sinks/stdout.py +0 -78
  22. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/LICENSE +0 -0
  23. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/README.md +0 -0
  24. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/pyproject.toml +0 -0
  25. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/setup.cfg +0 -0
  26. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/__init__.py +0 -0
  27. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/common.py +0 -0
  28. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/engine.py +0 -0
  29. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/history.py +0 -0
  30. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_client.py +0 -0
  31. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_index_sink.py +0 -0
  32. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_query_pipe.py +0 -0
  33. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/integrations/postgres_pipe.py +0 -0
  34. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/integrations/snowflake_pipe.py +0 -0
  35. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/log.py +0 -0
  36. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/parse_pjk_file.py +0 -0
  37. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/paths.py +0 -0
  38. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/__init__.py +0 -0
  39. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/ddiff.py +0 -0
  40. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/denorm.py +0 -0
  41. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/filter.py +0 -0
  42. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/head.py +0 -0
  43. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/move_field.py +0 -0
  44. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/progress_pipe.py +0 -0
  45. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/query_pipe.py +0 -0
  46. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/remove_field.py +0 -0
  47. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/sample.py +0 -0
  48. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/select.py +0 -0
  49. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/sort.py +0 -0
  50. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/tail.py +0 -0
  51. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/pipes/user_pipe_factory.py +0 -0
  52. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/registry.py +0 -0
  53. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/runtime.py +0 -0
  54. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/__init__.py +0 -0
  55. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/create_sink.py +0 -0
  56. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/csv_sink.py +0 -0
  57. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/devnull.py +0 -0
  58. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/dir_sink.py +0 -0
  59. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/expect.py +0 -0
  60. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/factory.py +0 -0
  61. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/format_sink.py +0 -0
  62. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph.py +0 -0
  63. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_axis.py +0 -0
  64. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_bar_line.py +0 -0
  65. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_cumulative.py +0 -0
  66. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_scatter.py +0 -0
  67. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/json_sink.py +0 -0
  68. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_sink.py +0 -0
  69. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_stream.py +0 -0
  70. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/sinks.py +0 -0
  71. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/tsv_sink.py +0 -0
  72. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sinks/user_sink_factory.py +0 -0
  73. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/__init__.py +0 -0
  74. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/csv_source.py +0 -0
  75. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/dict_list_source.py +0 -0
  76. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/dir_source.py +0 -0
  77. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/factory.py +0 -0
  78. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/favorite_source.py +0 -0
  79. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/format_source.py +0 -0
  80. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/http_source.py +0 -0
  81. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/inline_source.py +0 -0
  82. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/json_source.py +0 -0
  83. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file.py +0 -0
  84. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_local.py +0 -0
  85. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_s3.py +0 -0
  86. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/npy_source.py +0 -0
  87. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/parquet_source.py +0 -0
  88. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/s3_select_source.py +0 -0
  89. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/s3_source.py +0 -0
  90. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/source_list.py +0 -0
  91. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/sql_source.py +0 -0
  92. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/tsv_source.py +0 -0
  93. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/sources/user_source_factory.py +0 -0
  94. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/pjk/usage.py +0 -0
  95. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/dependency_links.txt +0 -0
  96. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/entry_points.txt +0 -0
  97. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/requires.txt +0 -0
  98. {python_jack_knife-0.7.7 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-jack-knife
3
- Version: 0.7.7
3
+ Version: 0.8.0
4
4
  Summary: Python Jack Knife – a command line data processor
5
5
  Author-email: Mike Schultz <mike.schultz@gmail.com>
6
6
  License:
@@ -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 get_unlookedup_records(self) -> List[Any]:
23
- # for outer join
24
- pass
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
@@ -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
@@ -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(sinks, stop_progress=(display.stop if display else None))
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
@@ -104,8 +104,9 @@ def display_configs():
104
104
  print(' ', highlight(name, 'bold', name))
105
105
  print('=========================================')
106
106
 
107
- if 'password' in body_dict:
108
- body_dict['password'] = '*************'
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(