kenze 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kenze/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """kenze - big-file data prep that never runs out of memory.
2
+
3
+ One `pip install`, then simple commands. DuckDB does the heavy lifting under
4
+ the hood (streaming, disk-spill, all cores); kenze just makes it a one-liner
5
+ and auto-configures memory so it doesn't OOM.
6
+
7
+ You can also use it from Python:
8
+
9
+ import kenze
10
+ kenze.sift("big.parquet", "clean.csv", keep=["id", "city"],
11
+ filter="amount > 0", sample=50000)
12
+ rows = kenze.sql("SELECT city, count(*) FROM 'big.parquet' GROUP BY 1")
13
+ kenze.profile("big.parquet")
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from .engine import connect
18
+ from .ops import (
19
+ build_query,
20
+ check,
21
+ diff,
22
+ join,
23
+ partition,
24
+ peek,
25
+ pivot,
26
+ profile,
27
+ run_spec,
28
+ run_sql,
29
+ split,
30
+ stats,
31
+ validate,
32
+ )
33
+
34
+ __version__ = "0.3.0"
35
+
36
+
37
+ def sift(input, output, con=None, quiet=True, **steps):
38
+ """Run one streaming pass. steps = keep/drop/filter/bbox/types/fillna/
39
+ mask/rename/dedup/sample/head (same words as the recipe / CLI)."""
40
+ spec = {"input": input, "output": output, **steps}
41
+ return run_spec(spec, con=con, quiet=quiet)
42
+
43
+
44
+ def run(recipe_path, variables=None, con=None, quiet=True):
45
+ """Run a .dq recipe file from Python."""
46
+ from .recipe import parse
47
+
48
+ with open(recipe_path, encoding="utf-8") as f:
49
+ spec = parse(f.read(), variables=variables)
50
+ return run_spec(spec, con=con, quiet=quiet)
51
+
52
+
53
+ def sql(query, con=None):
54
+ """Run any DuckDB SQL and get the rows back (window functions, aggregates)."""
55
+ own = con is None
56
+ con = con or connect()
57
+ try:
58
+ return con.execute(query).fetchall()
59
+ finally:
60
+ if own:
61
+ con.close()
62
+
63
+
64
+ __all__ = [
65
+ "connect", "sift", "run", "sql",
66
+ "run_spec", "build_query", "run_sql",
67
+ "profile", "stats", "peek", "check", "validate",
68
+ "join", "diff", "split", "partition", "pivot",
69
+ "__version__",
70
+ ]
kenze/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
kenze/cli.py ADDED
@@ -0,0 +1,294 @@
1
+ """`kenze` command-line front-end. Every transform builds a spec and hands it
2
+ to the same streaming engine, so a one-liner and a recipe run identically.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ import duckdb
10
+
11
+ from . import __version__
12
+ from .engine import connect
13
+ from .ops import (
14
+ check,
15
+ diff,
16
+ eject,
17
+ join,
18
+ partition,
19
+ peek,
20
+ pivot,
21
+ profile,
22
+ run_spec,
23
+ run_sql,
24
+ split,
25
+ stats,
26
+ validate,
27
+ )
28
+ from .recipe import REFERENCE
29
+ from .recipe import parse as parse_recipe
30
+
31
+ # global flags backfilled after parsing (SUPPRESS keeps them working both
32
+ # before AND after the subcommand without clobbering each other)
33
+ _GLOBAL_DEFAULTS = {
34
+ "memory_limit": None, "temp_dir": None, "no_disk_check": False,
35
+ "skip_bad_lines": False, "log": None, "quiet": False,
36
+ }
37
+
38
+
39
+ def _add_globals(parser):
40
+ g = parser.add_argument_group("global options")
41
+ s = argparse.SUPPRESS
42
+ g.add_argument("--memory-limit", type=float, metavar="GB", default=s,
43
+ help="pin the RAM budget in GB (reproducible runs); default auto-sizes to free RAM")
44
+ g.add_argument("--temp-dir", default=s, help="directory for disk-spill (default: system temp)")
45
+ g.add_argument("--no-disk-check", action="store_true", default=s,
46
+ help="skip the pre-flight free-space check")
47
+ g.add_argument("--skip-bad-lines", action="store_true", default=s,
48
+ help="ignore malformed rows in CSV input")
49
+ g.add_argument("--log", metavar="PATH", default=s,
50
+ help="write a run manifest (json) after a transform")
51
+ g.add_argument("-q", "--quiet", action="store_true", default=s,
52
+ help="suppress the summary line")
53
+
54
+
55
+ def _io(sp):
56
+ sp.add_argument("input")
57
+ sp.add_argument("-o", "--output", required=True, help="output file (or - for stdout)")
58
+
59
+
60
+ def build_parser():
61
+ common = argparse.ArgumentParser(add_help=False)
62
+ _add_globals(common)
63
+
64
+ p = argparse.ArgumentParser(
65
+ prog="kenze", parents=[common],
66
+ description="kenze - big-file data prep that never runs out of memory (DuckDB-powered).",
67
+ )
68
+ p.add_argument("--version", action="version", version=f"kenze {__version__}")
69
+ sub = p.add_subparsers(dest="cmd", required=True)
70
+
71
+ def cmd(name, **kw):
72
+ return sub.add_parser(name, parents=[common], **kw)
73
+
74
+ # inspect
75
+ cmd("profile", help="schema + row count, fast (no full load)").add_argument("input")
76
+ sp = cmd("peek", help="preview first rows + types + null counts")
77
+ sp.add_argument("input")
78
+ sp.add_argument("--n", type=int, default=20)
79
+ cmd("stats", help="per-column summary (min/max/nulls/unique)").add_argument("input")
80
+ cmd("check", help="pre-flight file-integrity scan").add_argument("input")
81
+ sp = cmd("validate", help="check a file against a target schema json")
82
+ sp.add_argument("input")
83
+ sp.add_argument("--schema", required=True)
84
+
85
+ # column ops
86
+ sp = cmd("keep", help="keep only these columns")
87
+ _io(sp)
88
+ sp.add_argument("--cols", required=True, help="comma-separated")
89
+ sp = cmd("drop", help="remove columns")
90
+ _io(sp)
91
+ sp.add_argument("--cols", required=True, help="comma-separated")
92
+ sp = cmd("rename", help="rename columns")
93
+ _io(sp)
94
+ sp.add_argument("--map", required=True, dest="mapping", help="old:new,old2:new2")
95
+ sp = cmd("cast", help="cast column types (e.g. keep leading zeros)")
96
+ _io(sp)
97
+ sp.add_argument("--types", required=True, help="col:TYPE,col:TYPE")
98
+ sp = cmd("fillna", help="replace nulls in columns with a value")
99
+ _io(sp)
100
+ sp.add_argument("--with", required=True, dest="fill", help="col:value,col:value")
101
+ sp = cmd("mask", help="mask sensitive columns (hash/redact/null)")
102
+ _io(sp)
103
+ sp.add_argument("--cols", required=True)
104
+ sp.add_argument("--method", default="hash")
105
+
106
+ # row ops
107
+ sp = cmd("filter", help="keep rows matching a SQL condition")
108
+ _io(sp)
109
+ sp.add_argument("--where", required=True)
110
+ sp = cmd("dedup", help="drop duplicate rows (default: whole-row)")
111
+ _io(sp)
112
+ sp.add_argument("--on", default="all", help="key column(s), comma-separated")
113
+ sp = cmd("sample", help="random N rows")
114
+ _io(sp)
115
+ sp.add_argument("--n", type=int, required=True)
116
+ sp = cmd("head", help="first N rows")
117
+ _io(sp)
118
+ sp.add_argument("--n", type=int, required=True)
119
+ sp = cmd("clip", help="keep only rows inside a lat/lon bounding box")
120
+ _io(sp)
121
+ sp.add_argument("--bbox", required=True,
122
+ help="min_lon,min_lat,max_lon,max_lat (use --bbox=-10,35,5,45 for negatives)")
123
+ _io(cmd("convert", help="change format (by output extension)"))
124
+
125
+ # combine / reshape / fan-out
126
+ sp = cmd("join", help="join two files on a key")
127
+ sp.add_argument("left")
128
+ sp.add_argument("right")
129
+ sp.add_argument("--on", required=True, help="key column(s)")
130
+ sp.add_argument("--how", default="inner", help="inner | left | right | full")
131
+ sp.add_argument("-o", "--output", required=True)
132
+ sp = cmd("diff", help="compare two datasets on a key")
133
+ sp.add_argument("old")
134
+ sp.add_argument("new")
135
+ sp.add_argument("--on", required=True)
136
+ sp.add_argument("-o", "--output", help="optional: write the changed keys")
137
+ sp = cmd("split", help="split one file into many by a column's values")
138
+ sp.add_argument("input")
139
+ sp.add_argument("--by", required=True)
140
+ sp.add_argument("-o", "--output", required=True, help="output DIRECTORY")
141
+ sp.add_argument("--format", default="csv", help="csv | parquet | json")
142
+ sp = cmd("partition", help="hive-style partitioned output (col=value/)")
143
+ sp.add_argument("input")
144
+ sp.add_argument("--by", required=True, help="column(s)")
145
+ sp.add_argument("-o", "--output", required=True, help="output DIRECTORY")
146
+ sp.add_argument("--format", default="parquet", help="parquet | csv")
147
+ sp = cmd("pivot", help="reshape long -> wide (values of a column become columns)")
148
+ sp.add_argument("input")
149
+ sp.add_argument("--on", required=True, help="column whose values become new columns")
150
+ sp.add_argument("--values", required=True, help="column to aggregate")
151
+ sp.add_argument("--agg", default="sum", help="sum | count | avg | min | max | median")
152
+ sp.add_argument("--group", help="row-identity column(s) to keep (optional)")
153
+ sp.add_argument("-o", "--output", required=True)
154
+
155
+ # power / interop
156
+ sp = cmd("sql", help="run any DuckDB SQL (window fns, pivots, ...)")
157
+ sp.add_argument("query")
158
+ sp.add_argument("-o", "--output", help="write result (omit to print)")
159
+ sp = cmd("eject", help="turn a recipe into raw SQL / Python (no lock-in)")
160
+ sp.add_argument("recipe")
161
+ sp.add_argument("--to", default="sql", help="sql | python")
162
+ sp = cmd("run", help="run a .dq recipe file")
163
+ sp.add_argument("recipe")
164
+ sp.add_argument("--set", action="append", default=[], metavar="K=V",
165
+ dest="variables", help="set a recipe variable (repeatable)")
166
+
167
+ cmd("recipe", help="show the recipe (.dq) format and every valid step")
168
+ return p
169
+
170
+
171
+ def _con(a):
172
+ return connect(
173
+ temp_dir=a.temp_dir,
174
+ memory_limit_gb=a.memory_limit,
175
+ progress=(not a.quiet) and sys.stderr.isatty(),
176
+ )
177
+
178
+
179
+ def _run(a, spec):
180
+ spec.setdefault("skip_bad_lines", a.skip_bad_lines)
181
+ con = _con(a)
182
+ try:
183
+ print(f"-> {a.cmd}")
184
+ run_spec(spec, con=con, quiet=a.quiet, disk_check=not a.no_disk_check, log=a.log)
185
+ finally:
186
+ con.close()
187
+
188
+
189
+ def _inspect(a):
190
+ """Read-only commands. Returns True if it handled the command."""
191
+ if a.cmd == "recipe":
192
+ print(REFERENCE)
193
+ elif a.cmd == "profile":
194
+ profile(a.input)
195
+ elif a.cmd == "peek":
196
+ peek(a.input, n=a.n)
197
+ elif a.cmd == "stats":
198
+ stats(a.input)
199
+ elif a.cmd == "check":
200
+ check(a.input)
201
+ elif a.cmd == "validate":
202
+ if validate(a.input, a.schema):
203
+ sys.exit(1)
204
+ else:
205
+ return False
206
+ return True
207
+
208
+
209
+ def _combine(a):
210
+ """Multi-file / fan-out commands. Returns True if it handled the command."""
211
+ if a.cmd == "split":
212
+ split(a.input, a.by, a.output, fmt=a.format)
213
+ elif a.cmd == "partition":
214
+ partition(a.input, a.by, a.output, fmt=a.format, con=_con(a), quiet=a.quiet)
215
+ elif a.cmd == "pivot":
216
+ print("-> pivot")
217
+ pivot(a.input, a.on, a.values, agg=a.agg, group=a.group, out=a.output,
218
+ con=_con(a), quiet=a.quiet, disk_check=not a.no_disk_check)
219
+ elif a.cmd == "join":
220
+ con = _con(a)
221
+ try:
222
+ print("-> join")
223
+ join(a.left, a.right, a.on, how=a.how, out=a.output, con=con,
224
+ quiet=a.quiet, disk_check=not a.no_disk_check)
225
+ finally:
226
+ con.close()
227
+ elif a.cmd == "diff":
228
+ diff(a.old, a.new, a.on, out=a.output, con=_con(a))
229
+ elif a.cmd == "sql":
230
+ run_sql(a.query, out=a.output, con=_con(a), quiet=a.quiet, disk_check=not a.no_disk_check)
231
+ elif a.cmd == "eject":
232
+ with open(a.recipe, "r", encoding="utf-8") as f:
233
+ spec = parse_recipe(f.read())
234
+ print(eject(spec, to=a.to))
235
+ elif a.cmd == "run":
236
+ variables = dict(kv.split("=", 1) for kv in a.variables if "=" in kv)
237
+ with open(a.recipe, "r", encoding="utf-8") as f:
238
+ spec = parse_recipe(f.read(), variables=variables)
239
+ spec.setdefault("skip_bad_lines", a.skip_bad_lines)
240
+ con = _con(a)
241
+ try:
242
+ print(f"-> running recipe {a.recipe}")
243
+ run_spec(spec, con=con, quiet=a.quiet, disk_check=not a.no_disk_check, log=a.log)
244
+ finally:
245
+ con.close()
246
+ else:
247
+ return False
248
+ return True
249
+
250
+
251
+ # cmd -> (attribute on parsed args, key in the spec)
252
+ _TRANSFORMS = {
253
+ "drop": ("cols", "drop"),
254
+ "keep": ("cols", "keep"),
255
+ "rename": ("mapping", "rename"),
256
+ "cast": ("types", "types"),
257
+ "fillna": ("fill", "fillna"),
258
+ "filter": ("where", "filter"),
259
+ "dedup": ("on", "dedup"),
260
+ "clip": ("bbox", "bbox"),
261
+ "sample": ("n", "sample"),
262
+ "head": ("n", "head"),
263
+ }
264
+
265
+
266
+ def _transform(a):
267
+ spec = {"input": a.input, "output": a.output}
268
+ if a.cmd == "mask":
269
+ spec["mask"] = a.cols
270
+ spec["mask_method"] = a.method
271
+ elif a.cmd in _TRANSFORMS:
272
+ attr, key = _TRANSFORMS[a.cmd]
273
+ spec[key] = getattr(a, attr)
274
+ # convert = no transforms, just re-COPY to the new format
275
+ _run(a, spec)
276
+
277
+
278
+ def _dispatch(a):
279
+ if _inspect(a) or _combine(a):
280
+ return
281
+ _transform(a)
282
+
283
+
284
+ def main(argv=None):
285
+ a = build_parser().parse_args(argv)
286
+ for k, v in _GLOBAL_DEFAULTS.items(): # backfill SUPPRESS'd globals
287
+ if not hasattr(a, k):
288
+ setattr(a, k, v)
289
+ try:
290
+ _dispatch(a)
291
+ except (ValueError, FileNotFoundError, OSError, duckdb.Error) as e:
292
+ sys.exit(f"Error: {e}")
293
+ except KeyboardInterrupt:
294
+ sys.exit("\ncancelled (no partial output was written)")
kenze/engine.py ADDED
@@ -0,0 +1,128 @@
1
+ """DuckDB connection, auto-tuned so big files don't OOM.
2
+
3
+ The whole anti-OOM story lives here:
4
+ - memory_limit is set to a fraction of *available* RAM (not total), so a
5
+ big job can't grab everything and get OS-killed while other apps run.
6
+ (psutil is a core dependency, so this always works.)
7
+ - temp_directory is set, so DuckDB spills to disk instead of dying when a
8
+ job genuinely needs more than RAM.
9
+ - preserve_insertion_order is off - a real memory saver on large writes.
10
+ - a fixed --memory-limit can pin the budget for reproducible / SLA runs.
11
+ - remote paths (s3://, gs://, https://, ...) transparently load httpfs.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import tempfile
17
+
18
+ import duckdb
19
+
20
+ REMOTE_PREFIXES = (
21
+ "s3://", "gs://", "gcs://", "r2://",
22
+ "az://", "azure://", "abfss://",
23
+ "http://", "https://",
24
+ )
25
+
26
+
27
+ def is_remote(path) -> bool:
28
+ return isinstance(path, str) and path.lower().startswith(REMOTE_PREFIXES)
29
+
30
+
31
+ def sql_path(p: str) -> str:
32
+ """Absolute, forward-slashed, single-quote-safe path for embedding in SQL.
33
+
34
+ Remote URLs are passed through untouched (only quote-escaped)."""
35
+ if is_remote(p):
36
+ return str(p).replace("'", "''")
37
+ return os.path.abspath(p).replace("\\", "/").replace("'", "''")
38
+
39
+
40
+ def _available_gb():
41
+ try:
42
+ import psutil # core dependency
43
+
44
+ return psutil.virtual_memory().available / 1e9
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def connect(
50
+ mem_fraction: float = 0.6,
51
+ threads=None,
52
+ temp_dir=None,
53
+ memory_limit_gb=None,
54
+ progress: bool = False,
55
+ ):
56
+ con = duckdb.connect()
57
+ con.execute(f"SET threads={threads or os.cpu_count() or 4}")
58
+
59
+ td = temp_dir or os.path.join(tempfile.gettempdir(), "kenze_spill")
60
+ os.makedirs(td, exist_ok=True)
61
+ con.execute(f"SET temp_directory='{sql_path(td)}'")
62
+
63
+ con.execute("SET preserve_insertion_order=false")
64
+
65
+ if memory_limit_gb:
66
+ con.execute(f"SET memory_limit='{max(1, int(memory_limit_gb))}GB'")
67
+ else:
68
+ avail = _available_gb()
69
+ if avail:
70
+ con.execute(f"SET memory_limit='{max(1, int(avail * mem_fraction))}GB'")
71
+
72
+ if progress:
73
+ for stmt in ("SET enable_progress_bar=true", "SET enable_progress_bar_print=true"):
74
+ try:
75
+ con.execute(stmt)
76
+ except duckdb.Error:
77
+ pass
78
+ return con
79
+
80
+
81
+ def ensure_remote(con, *paths):
82
+ """Load httpfs (and pick up cloud credentials from the environment) the
83
+ first time a remote path is used, so `kenze filter s3://bucket/x.parquet`
84
+ just works without downloading the file first."""
85
+ if not any(is_remote(p) for p in paths if p):
86
+ return
87
+ for stmt in ("INSTALL httpfs", "LOAD httpfs"):
88
+ try:
89
+ con.execute(stmt)
90
+ except duckdb.Error:
91
+ return
92
+
93
+ kid = os.environ.get("AWS_ACCESS_KEY_ID")
94
+ secret = os.environ.get("AWS_SECRET_ACCESS_KEY")
95
+ region = (os.environ.get("AWS_DEFAULT_REGION")
96
+ or os.environ.get("AWS_REGION") or "us-east-1")
97
+ token = os.environ.get("AWS_SESSION_TOKEN")
98
+
99
+ def esc(v):
100
+ return str(v).replace("'", "''")
101
+
102
+ try:
103
+ if kid and secret:
104
+ # explicit secret from env keys (no aws extension needed)
105
+ extra = f", SESSION_TOKEN '{esc(token)}'" if token else ""
106
+ con.execute(
107
+ f"CREATE OR REPLACE SECRET kenze_cloud (TYPE S3, "
108
+ f"KEY_ID '{esc(kid)}', SECRET '{esc(secret)}', "
109
+ f"REGION '{esc(region)}'{extra})"
110
+ )
111
+ else:
112
+ # fall back to the standard credential chain (config files, roles)
113
+ con.execute(
114
+ "CREATE SECRET IF NOT EXISTS kenze_cloud "
115
+ "(TYPE S3, PROVIDER credential_chain)"
116
+ )
117
+ except duckdb.Error:
118
+ pass
119
+
120
+
121
+ def temp_dir_of(con) -> str:
122
+ try:
123
+ td = con.execute("SELECT current_setting('temp_directory')").fetchone()[0]
124
+ if td:
125
+ return td
126
+ except duckdb.Error:
127
+ pass
128
+ return tempfile.gettempdir()