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.
Files changed (99) hide show
  1. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/PKG-INFO +1 -1
  2. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/__init__.py +2 -2
  3. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/components.py +11 -4
  4. python_jack_knife-0.8.0/src/pjk/engine.py +94 -0
  5. python_jack_knife-0.8.0/src/pjk/errors.py +79 -0
  6. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/history.py +4 -0
  7. python_jack_knife-0.8.0/src/pjk/integrations/rest_pipe.py +330 -0
  8. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/snowflake_pipe.py +1 -1
  9. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/log.py +37 -12
  10. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/main.py +23 -2
  11. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/man_page.py +9 -7
  12. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/parser.py +167 -17
  13. python_jack_knife-0.8.0/src/pjk/paths.py +53 -0
  14. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/factory.py +22 -6
  15. python_jack_knife-0.8.0/src/pjk/pipes/grab.py +116 -0
  16. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/join.py +15 -6
  17. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/let_reduce.py +203 -50
  18. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/map.py +11 -10
  19. python_jack_knife-0.8.0/src/pjk/pipes/stats.py +256 -0
  20. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/where.py +7 -4
  21. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/progress.py +36 -5
  22. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/registry.py +6 -3
  23. python_jack_knife-0.8.0/src/pjk/runtime.py +18 -0
  24. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/create_sink.py +2 -1
  25. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_hist.py +4 -5
  26. python_jack_knife-0.8.0/src/pjk/sinks/stdout.py +166 -0
  27. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/dict_list_source.py +1 -1
  28. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/favorite_source.py +3 -16
  29. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/usage.py +22 -12
  30. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/version.py +1 -1
  31. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/PKG-INFO +1 -1
  32. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/SOURCES.txt +6 -0
  33. python_jack_knife-0.7.6/src/pjk/engine.py +0 -51
  34. python_jack_knife-0.7.6/src/pjk/sinks/stdout.py +0 -78
  35. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/LICENSE +0 -0
  36. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/README.md +0 -0
  37. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/pyproject.toml +0 -0
  38. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/setup.cfg +0 -0
  39. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/common.py +0 -0
  40. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_client.py +0 -0
  41. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_index_sink.py +0 -0
  42. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/opensearch_query_pipe.py +0 -0
  43. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/integrations/postgres_pipe.py +0 -0
  44. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/parse_pjk_file.py +0 -0
  45. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/__init__.py +0 -0
  46. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/ddiff.py +0 -0
  47. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/denorm.py +0 -0
  48. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/filter.py +0 -0
  49. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/head.py +0 -0
  50. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/move_field.py +0 -0
  51. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/progress_pipe.py +0 -0
  52. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/query_pipe.py +0 -0
  53. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/remove_field.py +0 -0
  54. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/sample.py +0 -0
  55. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/select.py +0 -0
  56. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/sort.py +0 -0
  57. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/tail.py +0 -0
  58. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/pipes/user_pipe_factory.py +0 -0
  59. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/__init__.py +0 -0
  60. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/csv_sink.py +0 -0
  61. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/devnull.py +0 -0
  62. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/dir_sink.py +0 -0
  63. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/expect.py +0 -0
  64. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/factory.py +0 -0
  65. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/format_sink.py +0 -0
  66. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph.py +0 -0
  67. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_axis.py +0 -0
  68. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_bar_line.py +0 -0
  69. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_cumulative.py +0 -0
  70. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/graph_scatter.py +0 -0
  71. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/json_sink.py +0 -0
  72. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_sink.py +0 -0
  73. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/s3_stream.py +0 -0
  74. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/sinks.py +0 -0
  75. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/tsv_sink.py +0 -0
  76. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sinks/user_sink_factory.py +0 -0
  77. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/__init__.py +0 -0
  78. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/csv_source.py +0 -0
  79. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/dir_source.py +0 -0
  80. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/factory.py +0 -0
  81. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/format_source.py +0 -0
  82. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/http_source.py +0 -0
  83. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/inline_source.py +0 -0
  84. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/json_source.py +0 -0
  85. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file.py +0 -0
  86. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_local.py +0 -0
  87. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/lazy_file_s3.py +0 -0
  88. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/npy_source.py +0 -0
  89. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/parquet_source.py +0 -0
  90. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/s3_select_source.py +0 -0
  91. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/s3_source.py +0 -0
  92. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/source_list.py +0 -0
  93. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/sql_source.py +0 -0
  94. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/tsv_source.py +0 -0
  95. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/pjk/sources/user_source_factory.py +0 -0
  96. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/dependency_links.txt +0 -0
  97. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/entry_points.txt +0 -0
  98. {python_jack_knife-0.7.6 → python_jack_knife-0.8.0}/src/python_jack_knife.egg-info/requires.txt +0 -0
  99. {python_jack_knife-0.7.6 → 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.6
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:
@@ -1,6 +1,6 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # Copyright 2024 Mike Schultz
3
3
  from .version import __version__
4
- from .engine import PjkEngine
4
+ from .engine import PjkStream
5
5
 
6
- __all__ = ["__version__", "PjkEngine"]
6
+ __all__ = ["__version__", "PjkStream"]
@@ -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,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 ~/.pjk/component_configs.yaml under the arg name.
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, tempfile
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 ~/.pjk/logs by default; override with DJK_LOG_DIR / DJK_LOG_FILE.
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 or (logging.DEBUG if _truthy(os.getenv("DJK_DEBUG")) else logging.INFO)
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 ~/.pjk/logs
35
- log_dir = Path(os.getenv("DJK_LOG_DIR", Path.home() / ".pjk" / "logs"))
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.addHandler(fh)
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.addHandler(ch)
70
+ _configure_logger(logger, [ch], level)
58
71
  logger.warning("Falling back to console logging (log file not writable)")
59
72
 
60
- logger.setLevel(level)
61
- # Do not propagate to root
62
- logger.propagate = False
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)