python-jack-knife 0.7.1__tar.gz → 0.7.5__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 (92) hide show
  1. {python_jack_knife-0.7.1/src/python_jack_knife.egg-info → python_jack_knife-0.7.5}/PKG-INFO +2 -1
  2. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/pyproject.toml +1 -0
  3. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/__init__.py +2 -1
  4. python_jack_knife-0.7.5/src/pjk/engine.py +51 -0
  5. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/history.py +1 -1
  6. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/integrations/opensearch_query_pipe.py +1 -2
  7. python_jack_knife-0.7.5/src/pjk/integrations/postgres_pipe.py +285 -0
  8. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/main.py +4 -2
  9. python_jack_knife-0.7.5/src/pjk/parse_pjk_file.py +66 -0
  10. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/parser.py +108 -33
  11. python_jack_knife-0.7.5/src/pjk/pipes/ddiff.py +144 -0
  12. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/factory.py +44 -1
  13. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/let_reduce.py +137 -4
  14. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/query_pipe.py +31 -21
  15. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/registry.py +7 -0
  16. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/format_sink.py +2 -2
  17. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/graph.py +14 -0
  18. python_jack_knife-0.7.5/src/pjk/sinks/graph_axis.py +9 -0
  19. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/graph_bar_line.py +15 -2
  20. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/graph_cumulative.py +4 -0
  21. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/graph_hist.py +3 -0
  22. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/graph_scatter.py +4 -0
  23. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/csv_source.py +0 -2
  24. python_jack_knife-0.7.5/src/pjk/sources/dict_list_source.py +15 -0
  25. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/factory.py +18 -0
  26. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/format_source.py +17 -7
  27. python_jack_knife-0.7.5/src/pjk/sources/http_source.py +98 -0
  28. python_jack_knife-0.7.5/src/pjk/sources/s3_select_source.py +394 -0
  29. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/sql_source.py +13 -4
  30. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/usage.py +24 -2
  31. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/version.py +1 -1
  32. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5/src/python_jack_knife.egg-info}/PKG-INFO +2 -1
  33. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/python_jack_knife.egg-info/SOURCES.txt +7 -0
  34. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/python_jack_knife.egg-info/requires.txt +1 -0
  35. python_jack_knife-0.7.1/src/pjk/integrations/postgres_pipe.py +0 -227
  36. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/LICENSE +0 -0
  37. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/README.md +0 -0
  38. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/setup.cfg +0 -0
  39. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/common.py +0 -0
  40. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/components.py +0 -0
  41. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/integrations/opensearch_client.py +0 -0
  42. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/integrations/opensearch_index_sink.py +0 -0
  43. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/integrations/snowflake_pipe.py +0 -0
  44. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/log.py +0 -0
  45. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/man_page.py +0 -0
  46. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/__init__.py +0 -0
  47. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/denorm.py +0 -0
  48. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/filter.py +0 -0
  49. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/head.py +0 -0
  50. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/join.py +0 -0
  51. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/map.py +0 -0
  52. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/move_field.py +0 -0
  53. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/progress_pipe.py +0 -0
  54. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/remove_field.py +0 -0
  55. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/sample.py +0 -0
  56. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/select.py +0 -0
  57. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/sort.py +0 -0
  58. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/tail.py +0 -0
  59. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/user_pipe_factory.py +0 -0
  60. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/pipes/where.py +0 -0
  61. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/progress.py +0 -0
  62. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/__init__.py +0 -0
  63. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/create_sink.py +0 -0
  64. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/csv_sink.py +0 -0
  65. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/devnull.py +0 -0
  66. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/dir_sink.py +0 -0
  67. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/expect.py +0 -0
  68. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/factory.py +0 -0
  69. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/json_sink.py +0 -0
  70. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/s3_sink.py +0 -0
  71. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/s3_stream.py +0 -0
  72. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/sinks.py +0 -0
  73. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/stdout.py +0 -0
  74. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/tsv_sink.py +0 -0
  75. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sinks/user_sink_factory.py +0 -0
  76. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/__init__.py +0 -0
  77. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/dir_source.py +0 -0
  78. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/favorite_source.py +0 -0
  79. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/inline_source.py +0 -0
  80. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/json_source.py +0 -0
  81. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/lazy_file.py +0 -0
  82. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/lazy_file_local.py +0 -0
  83. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/lazy_file_s3.py +0 -0
  84. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/npy_source.py +0 -0
  85. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/parquet_source.py +0 -0
  86. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/s3_source.py +0 -0
  87. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/source_list.py +0 -0
  88. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/tsv_source.py +0 -0
  89. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/pjk/sources/user_source_factory.py +0 -0
  90. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/python_jack_knife.egg-info/dependency_links.txt +0 -0
  91. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/src/python_jack_knife.egg-info/entry_points.txt +0 -0
  92. {python_jack_knife-0.7.1 → python_jack_knife-0.7.5}/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.1
3
+ Version: 0.7.5
4
4
  Summary: Python Jack Knife – a command line data processor
5
5
  Author-email: Mike Schultz <mike.schultz@gmail.com>
6
6
  License:
@@ -212,6 +212,7 @@ License-File: LICENSE
212
212
  Requires-Dist: hjson>=3.1.0
213
213
  Requires-Dist: pyyaml>=6.0
214
214
  Requires-Dist: requests>=2.32.0
215
+ Requires-Dist: deepdiff<9,>=8.0.0
215
216
  Provides-Extra: aws
216
217
  Requires-Dist: boto3>=1.34; extra == "aws"
217
218
  Provides-Extra: postgres
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "hjson>=3.1.0",
18
18
  "pyyaml>=6.0",
19
19
  "requests>=2.32.0",
20
+ "deepdiff>=8.0.0,<9",
20
21
  ]
21
22
 
22
23
  [project.optional-dependencies]
@@ -1,5 +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
5
 
5
- __all__ = ["__version__"]
6
+ __all__ = ["__version__", "PjkEngine"]
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 Mike Schultz
3
+
4
+ from typing import Iterator, List, Optional
5
+
6
+ from pjk.parser import ExpressionParser, expand_macros
7
+ from pjk.registry import ComponentRegistry
8
+ from pjk.sources.dict_list_source import DictListSource
9
+
10
+
11
+ class PjkEngine:
12
+ """
13
+ Run a pjk pipeline from a .pjk file, optionally with supplied input records.
14
+
15
+ - inrecs supplied: the source in the .pjk file is replaced with inrecs.
16
+ Expression may be full (source + pipes + sink) or pipes-only.
17
+ - inrecs=None: expression.pjk is fully self-contained (source, pipes, sink)
18
+ """
19
+
20
+ def __init__(self, inrecs: Optional[List[dict]] = None, pjk_file: str = ""):
21
+ self.inrecs = inrecs
22
+ self.pjk_file = pjk_file
23
+
24
+ def __iter__(self) -> Iterator[dict]:
25
+ registry = ComponentRegistry()
26
+ parser = ExpressionParser(registry)
27
+ expanded = expand_macros([self.pjk_file])
28
+
29
+ if self.inrecs is not None:
30
+ source_override = DictListSource(self.inrecs)
31
+ try:
32
+ first_is_source = registry.create_source(expanded[0]) is not None
33
+ except Exception:
34
+ first_is_source = False
35
+ if first_is_source:
36
+ expanded = ["{to_override: 'true'}"] + expanded[1:]
37
+ else:
38
+ expanded = ["{to_override: 'true'}"] + expanded
39
+ else:
40
+ source_override = None
41
+
42
+ sink = parser.parse(expanded, source_override=source_override)
43
+
44
+ inputs = [sink.input]
45
+ sink.input._get_sources(inputs)
46
+ try:
47
+ for record in sink.input:
48
+ yield record
49
+ finally:
50
+ for inp in inputs:
51
+ inp.close()
@@ -91,7 +91,7 @@ def display_history():
91
91
 
92
92
  ordn = 1
93
93
  for command in reversed(clist):
94
- print(f'{ordn}\t{command}')
94
+ print(f'{ordn}\tpjk {command}')
95
95
  ordn += 1
96
96
 
97
97
  def get_history_tokens(ord_str: str):
@@ -108,8 +108,7 @@ class OpenSearchQueryPipe(QueryPipe, Integration):
108
108
  yield {
109
109
  "took_ms": took,
110
110
  "total_hits": total_hits,
111
- "index": self.index,
112
- "os_query_object": req_body
111
+ "index": self.index
113
112
  }
114
113
 
115
114
  # Emit each hit
@@ -0,0 +1,285 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 Mike Schultz
3
+ #
4
+ # djk/pipes/postgres_pipe.py
5
+
6
+ import base64
7
+ import datetime as _dt
8
+ import socket
9
+ import sys
10
+ import uuid
11
+ import time
12
+ from decimal import Decimal
13
+ from typing import Any, Dict, Optional
14
+
15
+ from pjk.usage import ParsedToken, Usage
16
+ from pjk.common import Integration
17
+ from pjk.pipes.query_pipe import QueryPipe
18
+
19
+ MAX_RETRIES = 3
20
+ BASE_DELAY = 0.1 # seconds
21
+
22
+
23
+ def _print_db_connect_failure(host: str, port: int, exc: BaseException) -> None:
24
+ print("Failed to connect to DB", file=sys.stderr)
25
+ cur: Optional[BaseException] = exc
26
+ while cur is not None:
27
+ if isinstance(cur, socket.gaierror):
28
+ print(
29
+ f" Could not resolve hostname {host!r} (port {port}). "
30
+ "Private or corporate DB hosts usually require VPN or split-DNS.",
31
+ file=sys.stderr,
32
+ )
33
+ return
34
+ cur = cur.__cause__
35
+
36
+
37
+ class DBClient:
38
+ """Per-instance pg8000 connection wrapper. No shared state."""
39
+
40
+ def __init__(
41
+ self,
42
+ host: str,
43
+ username: str,
44
+ password: Optional[str],
45
+ db_name: str,
46
+ port: int = 5432,
47
+ ssl: bool = False,
48
+ ):
49
+ import pg8000 # lazy import
50
+
51
+ kwargs = dict(
52
+ user=username,
53
+ password=password,
54
+ host=host,
55
+ database=db_name,
56
+ port=port,
57
+ )
58
+ if ssl:
59
+ import ssl as _ssl
60
+
61
+ kwargs["ssl_context"] = _ssl.create_default_context()
62
+
63
+ try:
64
+ self.conn = pg8000.connect(**kwargs)
65
+ self.conn.autocommit = True
66
+ except Exception as e:
67
+ _print_db_connect_failure(host, port, e)
68
+ raise
69
+
70
+ def close(self):
71
+ if getattr(self, "conn", None) is None:
72
+ return
73
+
74
+ import pg8000 # lazy
75
+
76
+ try:
77
+ self.conn.close()
78
+ except pg8000.exceptions.InterfaceError:
79
+ # Already closed / broken; ignore.
80
+ pass
81
+ finally:
82
+ self.conn = None
83
+
84
+
85
+ def _iso_dt(x: _dt.datetime) -> str:
86
+ """ISO 8601; normalize UTC offset to 'Z'."""
87
+ s = x.isoformat()
88
+ return s.replace("+00:00", "Z")
89
+
90
+
91
+ def normalize(obj: Any) -> Any:
92
+ """
93
+ Make values JSON/YAML-safe and portable (schema-agnostic):
94
+ - Decimal -> exact string (no sci-notation)
95
+ - date/datetime/time -> ISO-8601 string (datetime keeps offset; UTC -> 'Z')
96
+ - UUID -> string
97
+ - bytes -> base64 string
98
+ - lists/tuples/sets, dicts -> normalized recursively
99
+ - leaves int/float/str/bool/None as-is
100
+ """
101
+ if obj is None:
102
+ return None
103
+
104
+ if isinstance(obj, Decimal):
105
+ return format(obj, "f") # exact value as string
106
+
107
+ if isinstance(obj, _dt.datetime):
108
+ return _iso_dt(obj)
109
+
110
+ if isinstance(obj, (_dt.date, _dt.time)):
111
+ return obj.isoformat()
112
+
113
+ if isinstance(obj, uuid.UUID):
114
+ return str(obj)
115
+
116
+ if isinstance(obj, (bytes, bytearray, memoryview)):
117
+ return base64.b64encode(bytes(obj)).decode("ascii")
118
+
119
+ if isinstance(obj, dict):
120
+ return {k: normalize(v) for k, v in obj.items()}
121
+
122
+ if isinstance(obj, (list, tuple, set)):
123
+ return [normalize(v) for v in obj]
124
+
125
+ return obj
126
+
127
+
128
+ def _row_to_dict(cursor, row) -> Dict[str, Any]:
129
+ cols = [d[0] for d in cursor.description]
130
+ return {col: normalize(val) for col, val in zip(cols, row)}
131
+
132
+
133
+ class PostgresPipe(QueryPipe, Integration):
134
+ name = "postgres"
135
+ desc = "Postgres query pipe; executes SQL over input record['query']."
136
+ arg0 = ("instance", "instance of database.")
137
+ examples = [
138
+ ["myquery.sql", "postgres:mydb", "-"],
139
+ ["{'query': 'SELECT * from MY_TABLE;'}", "postgres:mydb", "-"],
140
+ ["{'query': 'SELECT * FROM pg_catalog.pg_tables;'}", "postgres:mydb"],
141
+ ["{'query': 'SELECT procedure_batch(%s, ...), batch_params:{...}"],
142
+ ["{'query': 'SELECT procedure_jsonb(%s, ...), json_params:json_string"],
143
+ ]
144
+
145
+ # name, type, default
146
+ config_tuples = [
147
+ ("db_name", str, None),
148
+ ("host", str, None),
149
+ ("user", str, None),
150
+ ("password", str, None),
151
+ ("port", int, 5432),
152
+ ("ssl", bool, False),
153
+ ]
154
+
155
+ def __init__(self, ptok: ParsedToken, u: Usage, root=None):
156
+ super().__init__(ptok, u, root=root)
157
+
158
+ self.db_name = u.get_config("db_name")
159
+ self.db_host = u.get_config("host")
160
+ self.db_user = u.get_config("user")
161
+ self.db_pass = u.get_config("password")
162
+ self.db_port = u.get_config("port")
163
+ self.db_ssl = u.get_config("ssl")
164
+
165
+ # Standard params field: single-exec params (list/tuple/dict/single value)
166
+ self.params_field = "params"
167
+
168
+ # Legacy batch path: list[tuple|list|dict] → executemany
169
+ self.batch_field = "batch_params"
170
+
171
+ # Explicit JSON payload field (no query sniffing).
172
+ # If present, this value is passed to cur.execute(query, json_params).
173
+ self.json_params_field = "json_params"
174
+
175
+ # One DB client (and thus one connection) per PostgresPipe instance.
176
+ # Under your invariant (one thread per pipe), this is thread-safe.
177
+ self.client = DBClient(
178
+ host=self.db_host,
179
+ username=self.db_user,
180
+ password=self.db_pass,
181
+ db_name=self.db_name,
182
+ port=self.db_port,
183
+ ssl=self.db_ssl,
184
+ )
185
+
186
+ def reset(self):
187
+ # stateless across reset
188
+ pass
189
+
190
+ def close(self):
191
+ if self.client is not None:
192
+ self.client.close()
193
+
194
+ def _make_header(self, cur, query: str, params=None) -> Dict[str, Any]:
195
+ """
196
+ Inspect the cursor and build a full header record.
197
+ Figures out result, rowcount, function automatically.
198
+ """
199
+ h = {
200
+ "db": self.db_name,
201
+ "dbhost": self.db_host,
202
+ }
203
+ if params is not None:
204
+ h["params"] = params
205
+
206
+ if cur.description:
207
+ cols = [d[0] for d in cur.description]
208
+ if len(cols) == 1 and cols[0] == "ingest_event":
209
+ _ = cur.fetchone() # consume void row
210
+ h["result"] = "ok"
211
+ h["function"] = "ingest_event"
212
+ else:
213
+ h["result"] = "ok"
214
+ h["rowcount"] = cur.rowcount if cur.rowcount != -1 else None
215
+ else:
216
+ h["result"] = "ok"
217
+ h["rowcount"] = cur.rowcount
218
+
219
+ return h
220
+
221
+ def execute_query_returning_S_xO_iterable(self, record):
222
+ query = record.get(self.query_field)
223
+ if not query:
224
+ record["_error"] = "missing query"
225
+ yield record
226
+ return
227
+
228
+ # Priority: json_params > batch_params > params
229
+ json_params = record.get(self.json_params_field, None)
230
+ batch = record.get(self.batch_field, None)
231
+ params = record.get(self.params_field, None)
232
+
233
+ cur = self.client.conn.cursor()
234
+ try:
235
+ did_executemany = False
236
+ header_params = None
237
+
238
+ # ---------- execute ----------
239
+ if json_params is not None:
240
+ # Explicit JSON payload; caller controls shape.
241
+ # We don't inspect query or payload.
242
+ if isinstance(json_params, (list, tuple, dict)):
243
+ cur.execute(query, json_params)
244
+ else:
245
+ cur.execute(query, (json_params,))
246
+ header_params = {self.json_params_field: json_params}
247
+
248
+ elif batch is not None:
249
+ # Legacy executemany path; no magic.
250
+ if len(batch) == 0:
251
+ cur.execute("SELECT 1")
252
+ header_params = {"batch_size": 0}
253
+ elif len(batch) == 1:
254
+ cur.execute(query, batch[0])
255
+ header_params = {"batch_size": 1, "params": batch[0]}
256
+ else:
257
+ cur.executemany(query, batch)
258
+ did_executemany = True
259
+ header_params = {"batch_size": len(batch)}
260
+
261
+ else:
262
+ # Single-statement path.
263
+ if params is None:
264
+ cur.execute(query)
265
+ header_params = None
266
+ else:
267
+ if isinstance(params, (list, tuple, dict)):
268
+ cur.execute(query, params)
269
+ else:
270
+ cur.execute(query, (params,))
271
+ header_params = params
272
+
273
+ # ---------- header ----------
274
+ yield self._make_header(cur, query, header_params)
275
+
276
+ # ---------- stream rows (only meaningful for single execute that returns rows) ----------
277
+ if not did_executemany and cur.description:
278
+ cols = [d[0] for d in cur.description]
279
+ if not (len(cols) == 1 and cols[0] == "ingest_event"):
280
+ for row in cur:
281
+ yield _row_to_dict(cur, row)
282
+
283
+ finally:
284
+ cur.close()
285
+ # connection stays open for this pipe; closed in .close()
@@ -14,7 +14,7 @@ import concurrent.futures
14
14
  from pjk.registry import ComponentRegistry
15
15
  from pjk.sinks.stdout import StdoutSink
16
16
  from pjk.man_page import do_man, do_examples, display_configs, display_macros
17
- from pjk.history import write_history, display_history, get_history_tokens
17
+ from pjk.history import write_history, display_history, get_history_tokens, printable_command
18
18
  from pjk.sinks.expect import ExpectSink
19
19
  from pjk.progress import ProgressDisplay
20
20
  from pjk.version import __version__
@@ -52,7 +52,6 @@ def execute_threaded(sinks, stop_progress=None):
52
52
 
53
53
  def initialize():
54
54
  init_logging()
55
- write_history(sys.argv[1:])
56
55
 
57
56
  #src = Path("src/pjk/resources/configs.tmpl")
58
57
  #dst_dir = Path.home() / ".pjk"
@@ -111,12 +110,15 @@ def execute_tokens(tokens: List[str]):
111
110
  if not tokens:
112
111
  print('No such history')
113
112
  return
113
+ cmd = printable_command(tokens)
114
+ print(f"pjk {cmd}")
114
115
 
115
116
  parser = ExpressionParser(registry)
116
117
 
117
118
  display = None
118
119
  try:
119
120
  sink = parser.parse(tokens)
121
+ write_history(sys.argv[1:]) # now that it's parsed sucessfully
120
122
  if not isinstance(sink, (StdoutSink | ExpectSink)):
121
123
  display = ProgressDisplay(interval=3.0)
122
124
  display.start()
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024 Mike Schultz
3
+
4
+ import os
5
+ import re
6
+ import shlex
7
+ from typing import Dict, List
8
+ from pjk.usage import TokenError, UsageError
9
+
10
+ PJK_END_TOKEN = 'END'
11
+ PJK_SET_TOKEN = 'SET'
12
+
13
+ # ${VAR} or $VAR - match anywhere in token (${VAR} first to avoid partial match)
14
+ VAR_REF_PATTERN = re.compile(r'\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)')
15
+
16
+
17
+ def _expand_token(t: str, env: Dict[str, str]) -> str:
18
+ """Expand $VAR or ${VAR} anywhere in token; raise if undefined."""
19
+
20
+ def repl(m):
21
+ name = m.group(1) or m.group(2)
22
+ if name not in env:
23
+ raise TokenError(f"Undefined variable: ${name}")
24
+ return env[name]
25
+
26
+ return VAR_REF_PATTERN.sub(repl, t)
27
+
28
+
29
+ def handle_pjk_file(token: str, expanded: List[str]):
30
+ if not token.endswith(".pjk"):
31
+ return False
32
+
33
+ if not os.path.isfile(token):
34
+ raise TokenError(f"pjk file not found: {token}")
35
+
36
+ with open(token, "r") as f:
37
+ lines = f.readlines()
38
+
39
+ env: Dict[str, str] = {}
40
+
41
+ for line in lines:
42
+ try:
43
+ parts = shlex.split(line, comments=True, posix=True)
44
+ except ValueError as e:
45
+ raise UsageError(f"Error parsing {token}: {e}")
46
+
47
+ if not parts:
48
+ continue
49
+ if parts[0] == PJK_END_TOKEN:
50
+ break
51
+
52
+ if parts[0] == PJK_SET_TOKEN:
53
+ for p in parts[1:]:
54
+ if '=' in p:
55
+ k, v = p.split('=', 1)
56
+ env[k.strip()] = v.strip()
57
+ continue
58
+
59
+ for p in parts:
60
+ if p == PJK_END_TOKEN:
61
+ break
62
+ expanded.append(_expand_token(p, env))
63
+ else:
64
+ continue
65
+ break
66
+ return True