python-jack-knife 0.7.6__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.
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/PKG-INFO +1 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/__init__.py +2 -2
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/components.py +11 -4
- python_jack_knife-0.8.0/src/pjk/engine.py +94 -0
- python_jack_knife-0.8.0/src/pjk/errors.py +79 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/history.py +4 -0
- python_jack_knife-0.8.0/src/pjk/integrations/rest_pipe.py +330 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/snowflake_pipe.py +1 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/log.py +37 -12
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/main.py +23 -2
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/man_page.py +9 -7
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/parser.py +167 -17
- python_jack_knife-0.8.0/src/pjk/paths.py +53 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/factory.py +22 -6
- python_jack_knife-0.8.0/src/pjk/pipes/grab.py +116 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/join.py +15 -6
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/let_reduce.py +203 -50
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/map.py +11 -10
- python_jack_knife-0.8.0/src/pjk/pipes/stats.py +256 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/where.py +7 -4
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/progress.py +36 -5
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/registry.py +6 -3
- python_jack_knife-0.8.0/src/pjk/runtime.py +18 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/create_sink.py +2 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_hist.py +4 -5
- python_jack_knife-0.8.0/src/pjk/sinks/stdout.py +166 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/dict_list_source.py +1 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/favorite_source.py +3 -16
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/usage.py +22 -12
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/version.py +1 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/PKG-INFO +1 -1
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/SOURCES.txt +6 -0
- python_jack_knife-0.7.6/src/pjk/engine.py +0 -51
- python_jack_knife-0.7.6/src/pjk/sinks/stdout.py +0 -78
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/LICENSE +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/README.md +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/pyproject.toml +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/setup.cfg +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/common.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_client.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_index_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_query_pipe.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/postgres_pipe.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/parse_pjk_file.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/__init__.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/ddiff.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/denorm.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/filter.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/head.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/move_field.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/progress_pipe.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/query_pipe.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/remove_field.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/sample.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/select.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/sort.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/tail.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/user_pipe_factory.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/__init__.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/csv_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/devnull.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/dir_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/expect.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/factory.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/format_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_axis.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_bar_line.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_cumulative.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_scatter.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/json_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_stream.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/sinks.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/tsv_sink.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/user_sink_factory.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/__init__.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/csv_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/dir_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/factory.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/format_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/http_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/inline_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/json_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_local.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_s3.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/npy_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/parquet_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/s3_select_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/s3_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/source_list.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/sql_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/tsv_source.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/user_source_factory.py +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/dependency_links.txt +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/entry_points.txt +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/requires.txt +0 -0
- {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/top_level.txt +0 -0
|
@@ -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
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024 Mike Schultz
|
|
3
|
+
|
|
4
|
+
import shlex
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterator, List, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
from pjk.log import init_stream
|
|
9
|
+
from pjk.parser import ExpressionParser, expand_macros
|
|
10
|
+
from pjk.registry import ComponentRegistry
|
|
11
|
+
from pjk.runtime import enter_pjk_stream, exit_pjk_stream
|
|
12
|
+
from pjk.sources.dict_list_source import DictListSource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PjkStream:
|
|
16
|
+
"""
|
|
17
|
+
Lazy record stream from a pjk pipeline.
|
|
18
|
+
|
|
19
|
+
Build with a named factory, then iterate:
|
|
20
|
+
|
|
21
|
+
for rec in PjkStream.expression('{foo: 1} select:foo -'):
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
The trailing sink token is required for parsing; records are taken from the
|
|
25
|
+
pipe chain (sink.drain is not called).
|
|
26
|
+
|
|
27
|
+
With inrecs supplied, the pipeline source is replaced by those records.
|
|
28
|
+
The expression may include a source (which is overridden) or be pipes-only.
|
|
29
|
+
|
|
30
|
+
PjkStream never writes CLI history and uses stderr logging (CloudWatch on AWS).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, tokens: Sequence[str], inrecs: Optional[List[dict]] = None):
|
|
34
|
+
self._tokens = list(tokens)
|
|
35
|
+
self._inrecs = inrecs
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def expression(cls, expr: str, *, inrecs: Optional[List[dict]] = None) -> "PjkStream":
|
|
39
|
+
"""From a CLI-style expression string (shlex-split)."""
|
|
40
|
+
try:
|
|
41
|
+
tokens = shlex.split(expr, comments=True, posix=True)
|
|
42
|
+
except ValueError as e:
|
|
43
|
+
raise ValueError(f"Invalid pjk expression: {e}") from e
|
|
44
|
+
return cls(tokens, inrecs)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def tokens(cls, tokens: Sequence[str], *, inrecs: Optional[List[dict]] = None) -> "PjkStream":
|
|
48
|
+
"""From an explicit token list."""
|
|
49
|
+
return cls(list(tokens), inrecs)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def file(cls, path: Union[Path, str], *, inrecs: Optional[List[dict]] = None) -> "PjkStream":
|
|
53
|
+
"""From a .pjk file path (also supports m: macros as a single token via tokens())."""
|
|
54
|
+
path = Path(path)
|
|
55
|
+
if path.suffix != ".pjk":
|
|
56
|
+
raise ValueError(f"PjkStream.file() expects a .pjk path, got: {path}")
|
|
57
|
+
return cls([str(path)], inrecs)
|
|
58
|
+
|
|
59
|
+
def __iter__(self) -> Iterator[dict]:
|
|
60
|
+
stream_token = enter_pjk_stream()
|
|
61
|
+
init_stream()
|
|
62
|
+
try:
|
|
63
|
+
yield from self._iter_records()
|
|
64
|
+
finally:
|
|
65
|
+
exit_pjk_stream(stream_token)
|
|
66
|
+
|
|
67
|
+
def _iter_records(self) -> Iterator[dict]:
|
|
68
|
+
registry = ComponentRegistry()
|
|
69
|
+
parser = ExpressionParser(registry)
|
|
70
|
+
expanded = expand_macros(self._tokens)
|
|
71
|
+
|
|
72
|
+
if self._inrecs is not None:
|
|
73
|
+
source_override = DictListSource(self._inrecs)
|
|
74
|
+
try:
|
|
75
|
+
first_is_source = registry.create_source(expanded[0]) is not None
|
|
76
|
+
except Exception:
|
|
77
|
+
first_is_source = False
|
|
78
|
+
if first_is_source:
|
|
79
|
+
expanded = ["{to_override: 'true'}"] + expanded[1:]
|
|
80
|
+
else:
|
|
81
|
+
expanded = ["{to_override: 'true'}"] + expanded
|
|
82
|
+
else:
|
|
83
|
+
source_override = None
|
|
84
|
+
|
|
85
|
+
sink = parser.parse(expanded, source_override=source_override)
|
|
86
|
+
|
|
87
|
+
inputs = [sink.input]
|
|
88
|
+
sink.input._get_sources(inputs)
|
|
89
|
+
try:
|
|
90
|
+
for record in sink.input:
|
|
91
|
+
yield record
|
|
92
|
+
finally:
|
|
93
|
+
for inp in inputs:
|
|
94
|
+
inp.close()
|
|
@@ -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)
|
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import shlex
|
|
4
4
|
from typing import List, Set
|
|
5
5
|
from pjk.common import pager_stdout, highlight
|
|
6
|
+
from pjk.runtime import pjk_stream_active
|
|
6
7
|
|
|
7
8
|
LOG_FILE = '.pjk-history.txt'
|
|
8
9
|
|
|
@@ -50,6 +51,9 @@ def read_history(log_path: str) -> List[int]:
|
|
|
50
51
|
return clist, cset
|
|
51
52
|
|
|
52
53
|
def write_history(tokens: list):
|
|
54
|
+
if pjk_stream_active():
|
|
55
|
+
return
|
|
56
|
+
|
|
53
57
|
if os.environ.get("PJK_NO_HISTORY") == "1":
|
|
54
58
|
return
|
|
55
59
|
|
|
@@ -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
|
|
@@ -124,7 +124,7 @@ class SnowflakeClient:
|
|
|
124
124
|
class SnowflakePipe(QueryPipe, Integration):
|
|
125
125
|
"""
|
|
126
126
|
Snowflake query pipe; executes SQL found in input record['query'] and streams rows.
|
|
127
|
-
Connection/session settings are pulled from
|
|
127
|
+
Connection/session settings are pulled from configs.yaml under PJK_HOME (or PJK_CONFIG_FILE).
|
|
128
128
|
"""
|
|
129
129
|
name = 'snowflake'
|
|
130
130
|
desc = "Snowflake query pipe; executes SQL over input record['query']."
|
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
# Copyright 2024 Mike Schultz
|
|
3
3
|
|
|
4
|
-
import logging, os,
|
|
4
|
+
import logging, os, sys
|
|
5
5
|
from logging.handlers import RotatingFileHandler
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Optional
|
|
8
8
|
|
|
9
|
+
from pjk.paths import logs_dir_path
|
|
10
|
+
|
|
9
11
|
logger = logging.getLogger("pjk")
|
|
10
12
|
|
|
11
13
|
def _truthy(v: Optional[str]) -> bool:
|
|
12
14
|
return str(v).lower() in ("1", "true", "yes", "on")
|
|
13
15
|
|
|
16
|
+
def _level_from_env(explicit: Optional[int]) -> int:
|
|
17
|
+
if explicit is not None:
|
|
18
|
+
return explicit
|
|
19
|
+
return logging.DEBUG if _truthy(os.getenv("DJK_DEBUG")) else logging.INFO
|
|
20
|
+
|
|
21
|
+
def _configure_logger(logger_obj, handlers, level: int) -> None:
|
|
22
|
+
logger_obj.handlers.clear()
|
|
23
|
+
for handler in handlers:
|
|
24
|
+
handler.setLevel(level)
|
|
25
|
+
logger_obj.addHandler(handler)
|
|
26
|
+
logger_obj.setLevel(level)
|
|
27
|
+
logger_obj.propagate = False
|
|
28
|
+
|
|
14
29
|
def init(force: bool = False, level: Optional[int] = None):
|
|
15
30
|
"""
|
|
16
31
|
Initialize 'pjk' logging.
|
|
17
32
|
|
|
18
33
|
- Rotates at DJK_LOG_MAX_MB (default 2 MB), keeps DJK_LOG_BACKUPS (default 3).
|
|
19
|
-
- Files under
|
|
34
|
+
- Files under PJK_HOME/logs by default; override with DJK_LOG_DIR / DJK_LOG_FILE.
|
|
20
35
|
- Set DJK_DEBUG=1|true|yes for DEBUG, else INFO (or pass explicit level).
|
|
21
36
|
- If the log directory is not writable, fall back to console logging
|
|
22
37
|
(stderr → CloudWatch in AWS).
|
|
@@ -26,13 +41,13 @@ def init(force: bool = False, level: Optional[int] = None):
|
|
|
26
41
|
return
|
|
27
42
|
logger.handlers.clear()
|
|
28
43
|
|
|
29
|
-
level = level
|
|
44
|
+
level = _level_from_env(level)
|
|
30
45
|
fmt = "[%(levelname)s] [%(threadName)s] %(message)s"
|
|
31
46
|
formatter = logging.Formatter(fmt)
|
|
32
47
|
|
|
33
48
|
try:
|
|
34
|
-
# Preferred: rotating file handler under
|
|
35
|
-
log_dir = Path(os.getenv("DJK_LOG_DIR",
|
|
49
|
+
# Preferred: rotating file handler under PJK_HOME/logs
|
|
50
|
+
log_dir = Path(os.getenv("DJK_LOG_DIR", logs_dir_path()))
|
|
36
51
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
37
52
|
|
|
38
53
|
log_file = log_dir / os.getenv("DJK_LOG_FILE", "pjk.log")
|
|
@@ -46,17 +61,27 @@ def init(force: bool = False, level: Optional[int] = None):
|
|
|
46
61
|
encoding="utf-8",
|
|
47
62
|
delay=False,
|
|
48
63
|
)
|
|
49
|
-
fh.setLevel(level)
|
|
50
64
|
fh.setFormatter(formatter)
|
|
51
|
-
logger
|
|
65
|
+
_configure_logger(logger, [fh], level)
|
|
52
66
|
except Exception:
|
|
53
67
|
# Fallback: console handler
|
|
54
68
|
ch = logging.StreamHandler()
|
|
55
|
-
ch.setLevel(level)
|
|
56
69
|
ch.setFormatter(formatter)
|
|
57
|
-
logger
|
|
70
|
+
_configure_logger(logger, [ch], level)
|
|
58
71
|
logger.warning("Falling back to console logging (log file not writable)")
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
def init_stream(force: bool = False, level: Optional[int] = None):
|
|
74
|
+
"""
|
|
75
|
+
Console-only logging for PjkStream and other embedded/library use.
|
|
76
|
+
|
|
77
|
+
Writes to stderr so AWS Lambda, ECS, and Fargate ship logs to CloudWatch
|
|
78
|
+
without relying on PJK_HOME/logs or a writable home directory.
|
|
79
|
+
"""
|
|
80
|
+
if logger.handlers and not force:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
level = _level_from_env(level)
|
|
84
|
+
fmt = "[%(levelname)s] [%(threadName)s] %(message)s"
|
|
85
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
86
|
+
handler.setFormatter(logging.Formatter(fmt))
|
|
87
|
+
_configure_logger(logger, [handler], level)
|