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 +70 -0
- kenze/__main__.py +4 -0
- kenze/cli.py +294 -0
- kenze/engine.py +128 -0
- kenze/ops.py +727 -0
- kenze/recipe.py +124 -0
- kenze-0.3.0.dist-info/METADATA +153 -0
- kenze-0.3.0.dist-info/RECORD +12 -0
- kenze-0.3.0.dist-info/WHEEL +5 -0
- kenze-0.3.0.dist-info/entry_points.txt +2 -0
- kenze-0.3.0.dist-info/licenses/LICENSE +21 -0
- kenze-0.3.0.dist-info/top_level.txt +1 -0
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
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()
|