File size: 9,911 Bytes
22de727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""Export the site database to Parquet for the Hugging Face release: one file per table of the 65 sites, the schema,
and the sites' static images. scripts/restore_site_db.py rebuilds the SQLite database from them.

Left out: session state (session_overlay, session_collection_replaced, sessions, event_log, which hold the writes of
every evaluation episode), the full-text indexes and the shadow tables SQLite keeps for them (rebuilt on restore),
and the tables of retired sites that are not among the 65.

    python scripts/export_site_db.py --db $MINIWEB_DB --static data/static --out data/release/miniweb-v1-hf/site_db
"""
import argparse, json, os, re, sqlite3

import pyarrow as pa
import pyarrow.parquet as pq
import yaml

ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SHADOW = ("_data", "_idx", "_content", "_docsize", "_config")
BATCH = 20000


def arrow_type(decl):
    """SQLite affinity of a declared type -> an Arrow type (column affinity rules, sqlite.org/datatype3.html)."""
    d = (decl or "").upper()
    if "INT" in d:
        return pa.int64()
    if any(k in d for k in ("CHAR", "CLOB", "TEXT")):
        return pa.string()
    if "BLOB" in d or not d:
        return None                                      # decided from the values
    if any(k in d for k in ("REAL", "FLOA", "DOUB")):
        return pa.float64()
    return None                                          # NUMERIC: decided from the values


def export_table(con, table, path):
    cols = con.execute(f'PRAGMA table_info("{table}")').fetchall()
    names = [c[1] for c in cols]
    types = {c[1]: arrow_type(c[2]) for c in cols}
    # a column whose stored values do not all fit its declared type (SQLite is dynamically typed) becomes text, or
    # binary if it holds blobs; untyped columns take the type of their values
    for n in names:
        kinds = {k for (k,) in con.execute(f'SELECT DISTINCT typeof("{n}") FROM "{table}"')} - {"null"}
        want = types[n]
        if "blob" in kinds:
            types[n] = pa.binary() if kinds == {"blob"} else pa.string()
        elif want is None or (want == pa.int64() and kinds - {"integer"}) or \
                (want == pa.float64() and kinds - {"real", "integer"}):
            types[n] = pa.int64() if kinds == {"integer"} else pa.float64() if kinds <= {"real", "integer"} and kinds \
                else pa.string()
    schema = pa.schema([(n, types[n]) for n in names])
    cast = {n: (str if types[n] == pa.string() else None) for n in names}
    cur = con.execute(f'SELECT * FROM "{table}"')
    writer, rows = pq.ParquetWriter(path, schema, compression="zstd"), 0
    while True:
        chunk = cur.fetchmany(BATCH)
        if not chunk:
            break
        data = {n: [None if r[i] is None else (cast[n](r[i]) if cast[n] else r[i]) for r in chunk]
                for i, n in enumerate(names)}
        writer.write_table(pa.table(data, schema=schema))
        rows += len(chunk)
    if rows == 0:
        writer.write_table(pa.table({n: [] for n in names}, schema=schema))
    writer.close()
    return rows


def export_media(media_dir, out, shard_mb=480):
    """The local copy of the sites' external media (scripts/fetch_site_media.py): Parquet shards of (url, path,
    content_type, bytes) under media/, and the manifest of every URL tried (status included) as media_manifest.parquet."""
    rows = [json.loads(line) for line in open(os.path.join(media_dir, "manifest.jsonl"))]
    pq.write_table(pa.table({k: [r.get(k) for r in rows] for k in ("url", "path", "bytes", "content_type")} |
                            {"status": [str(r.get("status")) for r in rows]}),
                   os.path.join(out, "media_manifest.parquet"))
    have = [r for r in rows if r.get("path") and os.path.isfile(os.path.join(media_dir, r["path"]))]
    os.makedirs(os.path.join(out, "media"), exist_ok=True)
    schema = pa.schema([("url", pa.string()), ("path", pa.string()), ("content_type", pa.string()), ("bytes", pa.binary())])
    shard, size, buf, files = 0, 0, [], 0
    def flush():
        nonlocal shard, size, buf
        if buf:
            pq.write_table(pa.Table.from_pylist(buf, schema=schema),
                           os.path.join(out, "media", f"media-{shard:05d}.parquet"), compression="zstd")
            shard, size, buf = shard + 1, 0, []
    for r in have:
        data = open(os.path.join(media_dir, r["path"]), "rb").read()
        buf.append({"url": r["url"], "path": r["path"], "content_type": r.get("content_type"), "bytes": data})
        size += len(data); files += 1
        if size > shard_mb * 2 ** 20:
            flush()
    flush()
    print(f"media: {files} files in {shard} shards; manifest of {len(rows)} URLs")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--db", default=os.environ.get("MINIWEB_DB"))
    ap.add_argument("--static", default=os.path.join(ROOT, "data", "static"))
    ap.add_argument("--sites", default=os.path.join(ROOT, "data", "sites.yaml"))
    ap.add_argument("--out", default=os.path.join(ROOT, "data", "release", "miniweb-v1-hf", "site_db"))
    ap.add_argument("--media", default=os.path.join(ROOT, "data", "media"),
                    help="the local media copy to pack (scripts/fetch_site_media.py); skipped if absent")
    ap.add_argument("--media-only", action="store_true")
    a = ap.parse_args()
    if a.media_only:
        export_media(a.media, a.out)
        return
    con = sqlite3.connect(f"file:{a.db}?mode=ro", uri=True)
    sites = [s["id"] for s in yaml.safe_load(open(a.sites))["sites"]]
    prefixes = sorted(((s, s.replace("-", "_") + "_") for s in sites), key=lambda kv: -len(kv[1]))
    owner = lambda t: next((s for s, p in prefixes if t.startswith(p)), None)  # noqa: E731
    master = con.execute("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL").fetchall()
    virtual = {n for t, n, _, s in master if t == "table" and s.upper().startswith("CREATE VIRTUAL")}
    shadow = {v + suf for v in virtual for suf in SHADOW}
    external = {n for t, n, _, s in master if n in virtual and re.search(r"content\s*=\s*\[?\w", s, re.I)
                and not re.search(r"content\s*=\s*''", s, re.I)}
    keep = {n for t, n, _, s in master if t == "table" and n not in shadow and n != "sqlite_sequence"
            and (owner(n) or n in ("filesystem_files", "site_registry"))
            and not (n in virtual and n.startswith("fts_") and owner(n[4:]) is None)}
    keep -= {n for n in keep if n.startswith("fts_") and owner(n[4:]) is None}
    # the full-text index of a kept table is rebuilt from its table; a full-text table with its own content is exported
    fts_of_kept = {n for n in virtual if n.startswith("fts_") and owner(n[4:])}
    data_tables = sorted((keep - fts_of_kept - (external & virtual)) | {"site_registry", "filesystem_files"})
    os.makedirs(a.out, exist_ok=True)
    order = {"table": 0, "index": 1, "trigger": 2}
    schema = []
    for t, n, tbl, s in sorted(master, key=lambda r: (order.get(r[0], 3), r[1])):
        base = tbl if t in ("index", "trigger") else n
        if base in keep or base in fts_of_kept:
            if t == "table" or base not in shadow:
                schema.append({"type": t, "name": n, "table": tbl, "sql": s, "virtual": n in virtual,
                               "rebuild": n in virtual and n in external})
    # some full-text indexes were built before rows were added to their tables and never updated; record which rows
    # such an index holds, so the restore can drop the others and search behaves exactly as when the tasks were made
    for o in schema:
        if o["type"] == "table" and o["rebuild"]:
            m = re.search(r"content\s*=\s*\[?(\w+)\]?", o["sql"], re.I)
            held = [r for (r,) in con.execute(f'SELECT id FROM "{o["name"]}_docsize" ORDER BY id')]
            if len(held) != con.execute(f'SELECT COUNT(*) FROM "{m.group(1)}"').fetchone()[0]:
                path = os.path.join(a.out, "fts_rowids", f'{o["name"]}.parquet')
                os.makedirs(os.path.dirname(path), exist_ok=True)
                pq.write_table(pa.table({"rowid": pa.array(held, pa.int64())}), path)
                o["rowids"] = os.path.relpath(path, a.out)
    json.dump(schema, open(os.path.join(a.out, "schema.json"), "w"), indent=1)
    manifest = []
    for t in data_tables:
        site = owner(t) or "_shared"
        d = os.path.join(a.out, "tables", site)
        os.makedirs(d, exist_ok=True)
        if t == "site_registry":
            path = os.path.join(d, f"{t}.parquet")
            rows = [r for r in con.execute("SELECT site, collection, table_name, pk_column FROM site_registry")
                    if r[2] in keep]
            pq.write_table(pa.table({k: [r[i] for r in rows] for i, k in
                                     enumerate(("site", "collection", "table_name", "pk_column"))}), path)
            n = len(rows)
        else:
            n = export_table(con, t, os.path.join(d, f"{t}.parquet"))
        manifest.append({"table": t, "site": site, "rows": n, "path": f"tables/{site}/{t}.parquet"})
        print(f"{t}: {n}", flush=True)
    json.dump(manifest, open(os.path.join(a.out, "tables.json"), "w"), indent=1)
    # the sites' static images (generated pictures, avatars, thumbnails), served from /static/<sub>/...
    files = [os.path.join(dp, f) for dp, _, fs in os.walk(a.static) for f in fs]
    pq.write_table(pa.table({"path": [os.path.relpath(f, a.static) for f in files],
                             "bytes": [open(f, "rb").read() for f in files]}), os.path.join(a.out, "static.parquet"))
    print(f"{len(data_tables)} tables, {sum(m['rows'] for m in manifest)} rows; {len(files)} static files; "
          f"schema of {len(schema)} objects")
    if os.path.isfile(os.path.join(a.media, "manifest.jsonl")):
        export_media(a.media, a.out)


if __name__ == "__main__":
    main()