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 +21 -0
- siftq-0.1.0/PKG-INFO +94 -0
- siftq-0.1.0/README.md +75 -0
- siftq-0.1.0/pyproject.toml +29 -0
- siftq-0.1.0/setup.cfg +4 -0
- siftq-0.1.0/src/sift/__init__.py +11 -0
- siftq-0.1.0/src/sift/__main__.py +4 -0
- siftq-0.1.0/src/sift/cli.py +102 -0
- siftq-0.1.0/src/sift/engine.py +45 -0
- siftq-0.1.0/src/sift/ops.py +158 -0
- siftq-0.1.0/src/sift/recipe.py +47 -0
- siftq-0.1.0/src/siftq.egg-info/PKG-INFO +94 -0
- siftq-0.1.0/src/siftq.egg-info/SOURCES.txt +15 -0
- siftq-0.1.0/src/siftq.egg-info/dependency_links.txt +1 -0
- siftq-0.1.0/src/siftq.egg-info/entry_points.txt +2 -0
- siftq-0.1.0/src/siftq.egg-info/requires.txt +4 -0
- siftq-0.1.0/src/siftq.egg-info/top_level.txt +1 -0
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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sift
|