From 76d54ae597e0f06698daa7e18cae0fc611c5977b Mon Sep 17 00:00:00 2001 From: Nathan TeBlunthuis Date: Mon, 7 Jul 2025 20:58:43 -0700 Subject: [PATCH] support partitioning output parquet by namespace. --- src/wikiq/__init__.py | 56 +++++++++++++----- test/Wikiq_Unit_Test.py | 13 ++++ .../collapse-user_sailormoon.parquet | Bin 0 -> 48452 bytes 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 test/baseline_output/collapse-user_sailormoon.parquet diff --git a/src/wikiq/__init__.py b/src/wikiq/__init__.py index fa4449d..5a69b6a 100755 --- a/src/wikiq/__init__.py +++ b/src/wikiq/__init__.py @@ -5,23 +5,21 @@ # additions_size deletions_size import argparse -import sys import os.path import re -from io import TextIOWrapper -from itertools import groupby - -from subprocess import Popen, PIPE +import sys from collections import deque from hashlib import sha1 -from typing import Any, IO, TextIO, Generator, Union +from io import TextIOWrapper +from itertools import groupby +from subprocess import PIPE, Popen +from typing import IO, Any, Generator, TextIO, Union -import mwxml -from mwxml import Dump - -from deltas.tokenizers import wikitext_split import mwpersistence import mwreverts +import mwxml +from deltas.tokenizers import wikitext_split +from mwxml import Dump import wikiq.tables as tables from wikiq.tables import RevisionTable @@ -29,11 +27,13 @@ from wikiq.wiki_diff_matcher import WikiDiffMatcher TO_ENCODE = ('title', 'editor') PERSISTENCE_RADIUS = 7 -from deltas import SequenceMatcher, SegmentMatcher +from pathlib import Path import pyarrow as pa -import pyarrow.parquet as pq import pyarrow.csv as pacsv +import pyarrow.parquet as pq +from deltas import SegmentMatcher, SequenceMatcher + class PersistMethod: none = 0 @@ -215,7 +215,8 @@ class WikiqParser: namespaces: Union[list[int], None] = None, revert_radius: int = 15, output_parquet: bool = True, - parquet_buffer_size: int = 2000 + parquet_buffer_size: int = 2000, + partition_namespaces: bool = False, ): """ @@ -230,6 +231,7 @@ class WikiqParser: self.revert_radius = revert_radius self.diff = diff self.text = text + self.partition_namespaces = partition_namespaces if namespaces is not None: self.namespace_filter = set(namespaces) else: @@ -374,7 +376,25 @@ class WikiqParser: if self.output_parquet: pageid_sortingcol = pq.SortingColumn(schema.get_field_index('pageid')) revid_sortingcol = pq.SortingColumn(schema.get_field_index('pageid')) - writer = pq.ParquetWriter(self.output_file, schema, flavor='spark', sorting_columns=[pageid_sortingcol, revid_sortingcol]) + sorting_cols = [pageid_sortingcol, revid_sortingcol] + + if self.partition_namespaces is False: + writer = pq.ParquetWriter(self.output_file, schema, flavor='spark', sorting_columns=sorting_cols) + else: + output_path = Path(self.output_file) + if self.namespace_filter is not None: + namespaces = self.namespace_filter + else: + namespaces = self.namespaces.values() + ns_paths = {ns: (output_path.parent / f"namespace={ns}") / output_path.name for ns in namespaces} + for path in ns_paths.values(): + Path(path).parent.mkdir(exist_ok=True, parents=True) + pq_writers = {ns: + pq.ParquetWriter(path, + schema, + flavor='spark', + sorting_columns=sorting_cols) for ns, path in ns_paths.items()} + else: writer = pacsv.CSVWriter(self.output_file, schema, write_options=pacsv.WriteOptions(delimiter='\t')) @@ -507,6 +527,9 @@ class WikiqParser: if not self.text: del row_buffer['text'] + if self.partition_namespaces is True: + writer = pq_writers[page.mwpage.namespace] + writer.write(pa.table(row_buffer, schema=schema)) page_count += 1 @@ -609,6 +632,10 @@ def main(): action='store_true', help="Output the text of the revision.") + parser.add_argument('-PNS', '--partition-namespaces', dest="partition_namespaces", default=False, + action='store_true', + help="Partition parquet files by namespace.") + parser.add_argument('--fandom-2020', dest="fandom_2020", action='store_true', help="Whether the archive is from the fandom 2020 dumps by Wikiteam. These dumps can have multiple .xml files in their archives.") @@ -670,6 +697,7 @@ def main(): text=args.text, diff=args.diff, output_parquet=output_parquet, + partition_namespaces=args.partition_namespaces ) wikiq.process() diff --git a/test/Wikiq_Unit_Test.py b/test/Wikiq_Unit_Test.py index 8191860..a29fab1 100644 --- a/test/Wikiq_Unit_Test.py +++ b/test/Wikiq_Unit_Test.py @@ -199,6 +199,19 @@ def test_collapse_user(): baseline = pd.read_table(tester.baseline_file) assert_frame_equal(test, baseline, check_like=True) +def test_partition_namespaces(): + tester = WikiqTester(SAILORMOON, "collapse-user", in_compression="7z", out_format='parquet', baseline_format='parquet') + + try: + tester.call_wikiq("--collapse-user", "--fandom-2020", "--partition-namespaces") + except subprocess.CalledProcessError as exc: + pytest.fail(exc.stderr.decode("utf8")) + + test = pd.read_parquet(os.path.join(tester.output,"namespace=10/sailormoon.parquet")) + baseline = pd.read_parquet(tester.baseline_file) + assert_frame_equal(test, baseline, check_like=True) + + def test_pwr_wikidiff2(): tester = WikiqTester(SAILORMOON, "persistence_wikidiff2", in_compression="7z") diff --git a/test/baseline_output/collapse-user_sailormoon.parquet b/test/baseline_output/collapse-user_sailormoon.parquet new file mode 100644 index 0000000000000000000000000000000000000000..0cb1db1166819fedc834de5665089b38b685de4f GIT binary patch literal 48452 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 literal 0 HcmV?d00001