siftq 0.1.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.
siftq-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ken
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
siftq-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: siftq
3
+ Version: 0.1.0
4
+ Summary: Big-file data prep that never runs out of memory — no SQL required. Powered by DuckDB.
5
+ Author: Ken
6
+ License: MIT
7
+ Keywords: duckdb,data,csv,parquet,etl,cli,big-data,dataframe
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Classifier: Topic :: Utilities
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: duckdb>=0.10
16
+ Provides-Extra: mem
17
+ Requires-Dist: psutil>=5.9; extra == "mem"
18
+ Dynamic: license-file
19
+
20
+ # sift
21
+
22
+ **Big-file data prep that never runs out of memory — no SQL required.**
23
+
24
+ `sift` is a tiny command-line tool for cleaning and reshaping data files
25
+ (CSV, Parquet, JSON) that are *too big for pandas*. It's a friendly front-end
26
+ over [DuckDB](https://duckdb.org): DuckDB does the heavy lifting (streaming,
27
+ disk-spill, all your CPU cores) and `sift` makes it a one-liner — and
28
+ auto-configures memory so your job doesn't crash.
29
+
30
+ ```bash
31
+ pip install siftq # pip name; the command is `dq`
32
+ ```
33
+
34
+ ## Why
35
+
36
+ - **It doesn't OOM.** Memory is capped to a fraction of *free* RAM and DuckDB
37
+ spills to disk instead of dying. Point it at a 10 GB file on a laptop; it's fine.
38
+ - **No SQL, no pandas.** Simple verbs, or a readable recipe file.
39
+ - **One streaming pass.** A whole recipe compiles to a single query — no
40
+ intermediate files, so it's fast and light.
41
+ - **Any format.** Reads/writes CSV, Parquet, JSON — auto-detected by extension.
42
+
43
+ ## One-liners
44
+
45
+ ```bash
46
+ dq profile data.parquet # schema + row count, instantly
47
+ dq keep data.parquet --cols id,lat,lon -o small.csv
48
+ dq drop data.csv --cols ip,ua -o clean.parquet
49
+ dq filter data.parquet --where "carrier = 'DU'" -o du.csv
50
+ dq dedup data.csv --on device_id -o unique.parquet
51
+ dq sample data.parquet --n 50000 -o sample.csv
52
+ dq clip pings.parquet --region uae -o uae.parquet
53
+ dq convert data.parquet -o data.csv # just change format
54
+ ```
55
+
56
+ ## Recipes
57
+
58
+ Chain steps in a readable `.dq` file — they run as one pass:
59
+
60
+ ```yaml
61
+ # du_clean.dq
62
+ input: data/raw/Feb2.parquet
63
+ keep: [device_id, lat, lon, carrier]
64
+ clip: uae # drop global GPS noise
65
+ filter: carrier = 'DU'
66
+ dedup: device_id
67
+ sample: 50000
68
+ output: outputs/du_clean.csv
69
+ ```
70
+
71
+ ```bash
72
+ dq run du_clean.dq
73
+ ```
74
+
75
+ Real run: **54.6M rows / 2.5 GB parquet → 50k-row clean CSV in ~1 second.**
76
+
77
+ ## Install for max memory-safety
78
+
79
+ ```bash
80
+ pip install "siftq[mem]" # adds psutil for smarter RAM sizing
81
+ ```
82
+
83
+ ## Roadmap
84
+
85
+ Core is intentionally small and grows release by release: `join`, `rename`,
86
+ `split` (by column into many files), `stats`, timestamp shifting, and an
87
+ optional geo/mobility extension. Ideas welcome.
88
+
89
+ ---
90
+
91
+ > **Naming:** the project is **sift**; it installs from PyPI as **`siftq`**
92
+ > (`sift` was taken) and gives you the **`dq`** command.
93
+
94
+ MIT licensed.
siftq-0.1.0/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # sift
2
+
3
+ **Big-file data prep that never runs out of memory — no SQL required.**
4
+
5
+ `sift` is a tiny command-line tool for cleaning and reshaping data files
6
+ (CSV, Parquet, JSON) that are *too big for pandas*. It's a friendly front-end
7
+ over [DuckDB](https://duckdb.org): DuckDB does the heavy lifting (streaming,
8
+ disk-spill, all your CPU cores) and `sift` makes it a one-liner — and
9
+ auto-configures memory so your job doesn't crash.
10
+
11
+ ```bash
12
+ pip install siftq # pip name; the command is `dq`
13
+ ```
14
+
15
+ ## Why
16
+
17
+ - **It doesn't OOM.** Memory is capped to a fraction of *free* RAM and DuckDB
18
+ spills to disk instead of dying. Point it at a 10 GB file on a laptop; it's fine.
19
+ - **No SQL, no pandas.** Simple verbs, or a readable recipe file.
20
+ - **One streaming pass.** A whole recipe compiles to a single query — no
21
+ intermediate files, so it's fast and light.
22
+ - **Any format.** Reads/writes CSV, Parquet, JSON — auto-detected by extension.
23
+
24
+ ## One-liners
25
+
26
+ ```bash
27
+ dq profile data.parquet # schema + row count, instantly
28
+ dq keep data.parquet --cols id,lat,lon -o small.csv
29
+ dq drop data.csv --cols ip,ua -o clean.parquet
30
+ dq filter data.parquet --where "carrier = 'DU'" -o du.csv
31
+ dq dedup data.csv --on device_id -o unique.parquet
32
+ dq sample data.parquet --n 50000 -o sample.csv
33
+ dq clip pings.parquet --region uae -o uae.parquet
34
+ dq convert data.parquet -o data.csv # just change format
35
+ ```
36
+
37
+ ## Recipes
38
+
39
+ Chain steps in a readable `.dq` file — they run as one pass:
40
+
41
+ ```yaml
42
+ # du_clean.dq
43
+ input: data/raw/Feb2.parquet
44
+ keep: [device_id, lat, lon, carrier]
45
+ clip: uae # drop global GPS noise
46
+ filter: carrier = 'DU'
47
+ dedup: device_id
48
+ sample: 50000
49
+ output: outputs/du_clean.csv
50
+ ```
51
+
52
+ ```bash
53
+ dq run du_clean.dq
54
+ ```
55
+
56
+ Real run: **54.6M rows / 2.5 GB parquet → 50k-row clean CSV in ~1 second.**
57
+
58
+ ## Install for max memory-safety
59
+
60
+ ```bash
61
+ pip install "siftq[mem]" # adds psutil for smarter RAM sizing
62
+ ```
63
+
64
+ ## Roadmap
65
+
66
+ Core is intentionally small and grows release by release: `join`, `rename`,
67
+ `split` (by column into many files), `stats`, timestamp shifting, and an
68
+ optional geo/mobility extension. Ideas welcome.
69
+
70
+ ---
71
+
72
+ > **Naming:** the project is **sift**; it installs from PyPI as **`siftq`**
73
+ > (`sift` was taken) and gives you the **`dq`** command.
74
+
75
+ MIT licensed.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "siftq" # pip name (brand: "sift"; command: dq)
7
+ version = "0.1.0"
8
+ description = "Big-file data prep that never runs out of memory — no SQL required. Powered by DuckDB."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ken" }]
13
+ keywords = ["duckdb", "data", "csv", "parquet", "etl", "cli", "big-data", "dataframe"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Scientific/Engineering",
18
+ "Topic :: Utilities",
19
+ ]
20
+ dependencies = ["duckdb>=0.10"]
21
+
22
+ [project.optional-dependencies]
23
+ mem = ["psutil>=5.9"] # better auto memory-sizing (optional)
24
+
25
+ [project.scripts]
26
+ dq = "sift.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
siftq-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """sift — 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); sift just makes it a one-liner
5
+ and auto-configures memory so it doesn't OOM.
6
+ """
7
+ from .engine import connect
8
+ from .ops import run_spec, profile, build_query
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["connect", "run_spec", "profile", "build_query", "__version__"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,102 @@
1
+ """`dq` command-line front-end. Every subcommand builds a spec and hands it to
2
+ the same streaming engine, so a one-liner and a recipe run identically.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+
8
+ from . import __version__
9
+ from .ops import profile, run_spec
10
+ from .recipe import parse as parse_recipe
11
+
12
+
13
+ def _io(sp):
14
+ sp.add_argument("input")
15
+ sp.add_argument("-o", "--output", required=True)
16
+
17
+
18
+ def build_parser():
19
+ p = argparse.ArgumentParser(
20
+ prog="dq",
21
+ description="sift - big-file data prep that never runs out of memory (DuckDB-powered).",
22
+ )
23
+ p.add_argument("--version", action="version", version=f"sift {__version__}")
24
+ sub = p.add_subparsers(dest="cmd", required=True)
25
+
26
+ sp = sub.add_parser("profile", help="schema + row count, fast (no full load)")
27
+ sp.add_argument("input")
28
+
29
+ sp = sub.add_parser("drop", help="remove columns")
30
+ _io(sp)
31
+ sp.add_argument("--cols", required=True, help="comma-separated")
32
+
33
+ sp = sub.add_parser("keep", help="keep only these columns")
34
+ _io(sp)
35
+ sp.add_argument("--cols", required=True, help="comma-separated")
36
+
37
+ sp = sub.add_parser("filter", help="keep rows matching a SQL condition")
38
+ _io(sp)
39
+ sp.add_argument("--where", required=True)
40
+
41
+ sp = sub.add_parser("dedup", help="drop duplicate rows (default: whole-row)")
42
+ _io(sp)
43
+ sp.add_argument("--on", default="all", help="key column(s), comma-separated")
44
+
45
+ sp = sub.add_parser("sample", help="random N rows")
46
+ _io(sp)
47
+ sp.add_argument("--n", type=int, required=True)
48
+
49
+ sp = sub.add_parser("head", help="first N rows")
50
+ _io(sp)
51
+ sp.add_argument("--n", type=int, required=True)
52
+
53
+ sp = sub.add_parser("clip", help="clip lat/lon to a region or bbox")
54
+ _io(sp)
55
+ sp.add_argument("--region", help="uae | ksa | world")
56
+ sp.add_argument("--bbox", help="min_lon,min_lat,max_lon,max_lat")
57
+
58
+ sp = sub.add_parser("convert", help="change format (by output extension)")
59
+ _io(sp)
60
+
61
+ sp = sub.add_parser("run", help="run a .dq recipe file")
62
+ sp.add_argument("recipe")
63
+
64
+ return p
65
+
66
+
67
+ def main(argv=None):
68
+ a = build_parser().parse_args(argv)
69
+
70
+ if a.cmd == "profile":
71
+ profile(a.input)
72
+ return
73
+
74
+ if a.cmd == "run":
75
+ with open(a.recipe, "r", encoding="utf-8") as f:
76
+ spec = parse_recipe(f.read())
77
+ print(f"-> running recipe {a.recipe}")
78
+ run_spec(spec)
79
+ return
80
+
81
+ spec = {"input": a.input, "output": a.output}
82
+ if a.cmd == "drop":
83
+ spec["drop"] = a.cols
84
+ elif a.cmd == "keep":
85
+ spec["keep"] = a.cols
86
+ elif a.cmd == "filter":
87
+ spec["filter"] = a.where
88
+ elif a.cmd == "dedup":
89
+ spec["dedup"] = a.on
90
+ elif a.cmd == "sample":
91
+ spec["sample"] = a.n
92
+ elif a.cmd == "head":
93
+ spec["head"] = a.n
94
+ elif a.cmd == "clip":
95
+ if a.region:
96
+ spec["clip"] = a.region
97
+ if a.bbox:
98
+ spec["bbox"] = a.bbox
99
+ # convert = no transforms, just re-COPY to the new format
100
+
101
+ print(f"-> {a.cmd}")
102
+ run_spec(spec)
@@ -0,0 +1,45 @@
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
+ - temp_directory is set, so DuckDB spills to disk instead of dying when a
7
+ job genuinely needs more than RAM.
8
+ - preserve_insertion_order is off — a real memory saver on large writes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import tempfile
14
+
15
+ import duckdb
16
+
17
+
18
+ def sql_path(p: str) -> str:
19
+ """Absolute, forward-slashed, single-quote-safe path for embedding in SQL."""
20
+ return os.path.abspath(p).replace("\\", "/").replace("'", "''")
21
+
22
+
23
+ def _available_gb():
24
+ try:
25
+ import psutil # optional extra
26
+
27
+ return psutil.virtual_memory().available / 1e9
28
+ except Exception:
29
+ return None
30
+
31
+
32
+ def connect(mem_fraction: float = 0.6, threads=None, temp_dir=None):
33
+ con = duckdb.connect()
34
+ con.execute(f"SET threads={threads or os.cpu_count() or 4}")
35
+
36
+ td = temp_dir or os.path.join(tempfile.gettempdir(), "sift_spill")
37
+ os.makedirs(td, exist_ok=True)
38
+ con.execute(f"SET temp_directory='{sql_path(td)}'")
39
+
40
+ con.execute("SET preserve_insertion_order=false")
41
+
42
+ avail = _available_gb()
43
+ if avail:
44
+ con.execute(f"SET memory_limit='{max(1, int(avail * mem_fraction))}GB'")
45
+ return con
@@ -0,0 +1,158 @@
1
+ """The operations. Each maps a simple intent to a DuckDB SQL fragment, and a
2
+ whole recipe compiles to ONE streaming query (not N passes writing N temp
3
+ files) — which is what makes it both memory-safe and fast.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import time
9
+
10
+ from .engine import connect, sql_path
11
+
12
+ LAT_NAMES = {"lat", "latitude"}
13
+ LON_NAMES = {"lon", "lng", "long", "longitude"}
14
+
15
+ # name -> (min_lon, min_lat, max_lon, max_lat)
16
+ BBOX = {
17
+ "uae": (51.0, 22.0, 56.6, 26.6),
18
+ "ksa": (34.0, 16.0, 56.0, 33.0),
19
+ "world": (-180.0, -90.0, 180.0, 90.0),
20
+ }
21
+
22
+
23
+ def _ident(c: str) -> str:
24
+ return '"' + str(c).replace('"', '""') + '"'
25
+
26
+
27
+ def _source(path: str) -> str:
28
+ # DuckDB auto-detects csv / parquet / json by extension.
29
+ return "'" + sql_path(path) + "'"
30
+
31
+
32
+ def _aslist(v):
33
+ if isinstance(v, (list, tuple)):
34
+ return [str(x).strip() for x in v if str(x).strip()]
35
+ return [s.strip() for s in str(v).split(",") if s.strip()]
36
+
37
+
38
+ def columns(con, source: str):
39
+ return [r[0] for r in con.execute(f"DESCRIBE SELECT * FROM {source}").fetchall()]
40
+
41
+
42
+ def _find(cols, names):
43
+ for c in cols:
44
+ if c.lower() in names:
45
+ return c
46
+ return None
47
+
48
+
49
+ def _clip_sql(spec, cols):
50
+ box = None
51
+ if spec.get("clip"):
52
+ key = str(spec["clip"]).lower()
53
+ if key not in BBOX:
54
+ raise ValueError(
55
+ f"unknown region '{key}'. known: {', '.join(BBOX)} "
56
+ f"(or use bbox: min_lon,min_lat,max_lon,max_lat)"
57
+ )
58
+ box = BBOX[key]
59
+ if spec.get("bbox"):
60
+ v = spec["bbox"]
61
+ v = [float(x) for x in v.split(",")] if isinstance(v, str) else [float(x) for x in v]
62
+ box = tuple(v)
63
+ if not box:
64
+ return None
65
+ lat, lon = _find(cols, LAT_NAMES), _find(cols, LON_NAMES)
66
+ if not lat or not lon:
67
+ raise ValueError("clip needs latitude/longitude columns (lat/latitude, lon/longitude)")
68
+ mnlon, mnlat, mxlon, mxlat = box
69
+ return f"{_ident(lon)} BETWEEN {mnlon} AND {mxlon} AND {_ident(lat)} BETWEEN {mnlat} AND {mxlat}"
70
+
71
+
72
+ def build_query(con, spec) -> str:
73
+ src = _source(spec["input"])
74
+ cols = columns(con, src)
75
+
76
+ if spec.get("keep"):
77
+ sel = ", ".join(_ident(c) for c in _aslist(spec["keep"]))
78
+ elif spec.get("drop"):
79
+ drop = {c.lower() for c in _aslist(spec["drop"])}
80
+ sel = ", ".join(_ident(c) for c in cols if c.lower() not in drop)
81
+ if not sel:
82
+ raise ValueError("drop removed every column")
83
+ else:
84
+ sel = "*"
85
+
86
+ where = []
87
+ if spec.get("filter"):
88
+ where.append(f"({spec['filter']})")
89
+ clip = _clip_sql(spec, cols)
90
+ if clip:
91
+ where.append(clip)
92
+
93
+ q = f"SELECT {sel} FROM {src}"
94
+ if where:
95
+ q += " WHERE " + " AND ".join(where)
96
+
97
+ if spec.get("dedup"):
98
+ keys = _aslist(spec["dedup"])
99
+ if [k.lower() for k in keys] == ["all"]:
100
+ q = f"SELECT DISTINCT * FROM ({q}) _q"
101
+ else:
102
+ q = f"SELECT DISTINCT ON ({', '.join(_ident(k) for k in keys)}) * FROM ({q}) _q"
103
+
104
+ if spec.get("sample"):
105
+ q = f"SELECT * FROM ({q}) _q USING SAMPLE {int(spec['sample'])} ROWS"
106
+
107
+ if spec.get("head"):
108
+ q = f"SELECT * FROM ({q}) _q LIMIT {int(spec['head'])}"
109
+
110
+ return q
111
+
112
+
113
+ def _copy_opts(path: str) -> str:
114
+ ext = os.path.splitext(path)[1].lower()
115
+ if ext == ".tsv":
116
+ return "(HEADER, DELIMITER '\t')"
117
+ if ext in (".parquet", ".pq"):
118
+ return "(FORMAT PARQUET)"
119
+ if ext in (".json", ".ndjson"):
120
+ return "(FORMAT JSON)"
121
+ return "(HEADER, DELIMITER ',')" # csv default
122
+
123
+
124
+ def run_spec(spec, con=None, quiet=False) -> int:
125
+ own = con is None
126
+ con = con or connect()
127
+ try:
128
+ t0 = time.time()
129
+ q = build_query(con, spec)
130
+ out = spec["output"]
131
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
132
+ dst = "'" + sql_path(out) + "'"
133
+ n = con.execute(f"COPY ({q}) TO {dst} {_copy_opts(out)}").fetchone()[0]
134
+ if not quiet:
135
+ print(f" done: {n:,} rows -> {out} ({time.time() - t0:,.1f}s)")
136
+ return n
137
+ finally:
138
+ if own:
139
+ con.close()
140
+
141
+
142
+ def profile(path, con=None) -> int:
143
+ own = con is None
144
+ con = con or connect()
145
+ try:
146
+ src = _source(path)
147
+ schema = con.execute(f"DESCRIBE SELECT * FROM {src}").fetchall()
148
+ n = con.execute(f"SELECT count(*) FROM {src}").fetchone()[0]
149
+ size = os.path.getsize(path) if os.path.exists(path) else 0
150
+ print(f"\n {os.path.basename(path)}")
151
+ print(f" {n:,} rows | {len(schema)} columns | {size / 1e9:,.2f} GB on disk\n")
152
+ for row in schema:
153
+ print(f" {row[0]:<22} {row[1]}")
154
+ print()
155
+ return n
156
+ finally:
157
+ if own:
158
+ con.close()
@@ -0,0 +1,47 @@
1
+ """Tiny recipe parser — a readable .dq file, no YAML dependency.
2
+
3
+ input: data/raw/Feb2.parquet
4
+ keep: [device_id, lat, lon, carrier]
5
+ clip: uae
6
+ filter: carrier = 'DU'
7
+ sample: 50000
8
+ output: outputs/du_clean.csv
9
+
10
+ Only rule: one `key: value` per line, `[a, b, c]` for lists, `#` comments.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ _REQUIRED = ("input", "output")
15
+
16
+
17
+ def _strip_comment(line: str) -> str:
18
+ """Drop a trailing #comment, but not a # that sits inside quotes."""
19
+ inq = None
20
+ for i, ch in enumerate(line):
21
+ if inq:
22
+ if ch == inq:
23
+ inq = None
24
+ elif ch in "\"'":
25
+ inq = ch
26
+ elif ch == "#":
27
+ return line[:i]
28
+ return line
29
+
30
+
31
+ def parse(text: str) -> dict:
32
+ spec: dict = {}
33
+ for raw in text.splitlines():
34
+ s = _strip_comment(raw).strip()
35
+ if not s or ":" not in s:
36
+ continue
37
+ key, val = s.split(":", 1) # split on FIRST colon (keeps C:\ paths intact)
38
+ key, val = key.strip().lower(), val.strip()
39
+ if val.startswith("[") and val.endswith("]"):
40
+ val = [x.strip() for x in val[1:-1].split(",") if x.strip()]
41
+ elif (val[:1], val[-1:]) in (('"', '"'), ("'", "'")):
42
+ val = val[1:-1]
43
+ spec[key] = val
44
+ missing = [k for k in _REQUIRED if k not in spec]
45
+ if missing:
46
+ raise ValueError(f"recipe is missing: {', '.join(missing)}")
47
+ return spec
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: siftq
3
+ Version: 0.1.0
4
+ Summary: Big-file data prep that never runs out of memory — no SQL required. Powered by DuckDB.
5
+ Author: Ken
6
+ License: MIT
7
+ Keywords: duckdb,data,csv,parquet,etl,cli,big-data,dataframe
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Classifier: Topic :: Utilities
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: duckdb>=0.10
16
+ Provides-Extra: mem
17
+ Requires-Dist: psutil>=5.9; extra == "mem"
18
+ Dynamic: license-file
19
+
20
+ # sift
21
+
22
+ **Big-file data prep that never runs out of memory — no SQL required.**
23
+
24
+ `sift` is a tiny command-line tool for cleaning and reshaping data files
25
+ (CSV, Parquet, JSON) that are *too big for pandas*. It's a friendly front-end
26
+ over [DuckDB](https://duckdb.org): DuckDB does the heavy lifting (streaming,
27
+ disk-spill, all your CPU cores) and `sift` makes it a one-liner — and
28
+ auto-configures memory so your job doesn't crash.
29
+
30
+ ```bash
31
+ pip install siftq # pip name; the command is `dq`
32
+ ```
33
+
34
+ ## Why
35
+
36
+ - **It doesn't OOM.** Memory is capped to a fraction of *free* RAM and DuckDB
37
+ spills to disk instead of dying. Point it at a 10 GB file on a laptop; it's fine.
38
+ - **No SQL, no pandas.** Simple verbs, or a readable recipe file.
39
+ - **One streaming pass.** A whole recipe compiles to a single query — no
40
+ intermediate files, so it's fast and light.
41
+ - **Any format.** Reads/writes CSV, Parquet, JSON — auto-detected by extension.
42
+
43
+ ## One-liners
44
+
45
+ ```bash
46
+ dq profile data.parquet # schema + row count, instantly
47
+ dq keep data.parquet --cols id,lat,lon -o small.csv
48
+ dq drop data.csv --cols ip,ua -o clean.parquet
49
+ dq filter data.parquet --where "carrier = 'DU'" -o du.csv
50
+ dq dedup data.csv --on device_id -o unique.parquet
51
+ dq sample data.parquet --n 50000 -o sample.csv
52
+ dq clip pings.parquet --region uae -o uae.parquet
53
+ dq convert data.parquet -o data.csv # just change format
54
+ ```
55
+
56
+ ## Recipes
57
+
58
+ Chain steps in a readable `.dq` file — they run as one pass:
59
+
60
+ ```yaml
61
+ # du_clean.dq
62
+ input: data/raw/Feb2.parquet
63
+ keep: [device_id, lat, lon, carrier]
64
+ clip: uae # drop global GPS noise
65
+ filter: carrier = 'DU'
66
+ dedup: device_id
67
+ sample: 50000
68
+ output: outputs/du_clean.csv
69
+ ```
70
+
71
+ ```bash
72
+ dq run du_clean.dq
73
+ ```
74
+
75
+ Real run: **54.6M rows / 2.5 GB parquet → 50k-row clean CSV in ~1 second.**
76
+
77
+ ## Install for max memory-safety
78
+
79
+ ```bash
80
+ pip install "siftq[mem]" # adds psutil for smarter RAM sizing
81
+ ```
82
+
83
+ ## Roadmap
84
+
85
+ Core is intentionally small and grows release by release: `join`, `rename`,
86
+ `split` (by column into many files), `stats`, timestamp shifting, and an
87
+ optional geo/mobility extension. Ideas welcome.
88
+
89
+ ---
90
+
91
+ > **Naming:** the project is **sift**; it installs from PyPI as **`siftq`**
92
+ > (`sift` was taken) and gives you the **`dq`** command.
93
+
94
+ MIT licensed.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/sift/__init__.py
5
+ src/sift/__main__.py
6
+ src/sift/cli.py
7
+ src/sift/engine.py
8
+ src/sift/ops.py
9
+ src/sift/recipe.py
10
+ src/siftq.egg-info/PKG-INFO
11
+ src/siftq.egg-info/SOURCES.txt
12
+ src/siftq.egg-info/dependency_links.txt
13
+ src/siftq.egg-info/entry_points.txt
14
+ src/siftq.egg-info/requires.txt
15
+ src/siftq.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dq = sift.cli:main
@@ -0,0 +1,4 @@
1
+ duckdb>=0.10
2
+
3
+ [mem]
4
+ psutil>=5.9
@@ -0,0 +1 @@
1
+ sift