35 Commits

Author SHA1 Message Date
e4c05caf94 document regex match counting in the README
Describes -RPc and -CPc alongside the regex matching options they
modify. Kept with the feature rather than on the release branch, so the
README there documents only what that branch actually provides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:13:34 -07:00
8056c91ac8 add regex match counting (-RPc / -CPc)
Port the regex counting feature from the mako_changes-20230429 branch
(2ff4d60): the -RPc/--revision-pattern-count and
-CPc/--comment-pattern-count flags cause revision and comment patterns
to output the number of matches instead of the matched text, with 0
(never null) for revisions with no matches or deleted content. The
flags apply to all revision or comment patterns respectively, and count
columns are typed int64 in the output schema.

Counting the matches of common or large patterns previously required
returning the full matched text, which made for very large outputs.

Also take the code review suggestion from that branch (933ca75): use
assignment expressions so matching no longer makes redundant calls to
search() before finditer()/findall() or calls match.group() twice.

Includes unit tests for the count semantics and schema types, and an
end-to-end test that verifies counts against the number of matches
recomputed from each revision's text and comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 0b6e0fbc70)
2026-08-13 17:13:34 -07:00
57988849cc commit the uv lockfile
wikiq produces datasets that get analyzed for years, and reproducing a
run means reproducing the dependency versions that produced it. Without
a lockfile in the repository there is no record of what a given output
was actually generated with, and the loose lower bounds in pyproject.toml
will resolve differently over time.

The lockfile pins all 72 packages in the resolution, including the
transitive ones from the mediawiki-utilities stack that are old enough to
be worth pinning explicitly.

This does not constrain anyone installing with pip, which ignores the
lockfile; it records what `uv sync` resolves so that a run can be
reproduced deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:13:14 -07:00
268c212469 sort the imports in wikiq/__init__.py
Four imports sat below the module constants, where pathlib and pyarrow
had drifted after some earlier edit, and the local wikiq imports were
mixed in with third-party ones. Group them as standard library,
third-party, then local, each alphabetized, and put the version lookup
and the constants after all of them.

Pure movement: no import added or removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:53:52 -07:00
8bd6c2e129 add a CHANGELOG
Two version tags existed with no record of what was in them. Since
neither was ever released, the first entry describes 0.3.0 against what
people are actually running rather than against a previous release.

The redirect column change leads, because it is the one thing here that
will break code someone else has written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:53:52 -07:00
0fa8b3d9ba put the base install ahead of the optional features in the README
The installation section led with the two optional dependencies and left
the decompression tools until last, which put the awkward parts first
and buried something every run actually needs. Describe the base install
and the compression handling together, then the optional features under
their own headings.

Drop the note about uv. It said only that uv also works, which anyone
who uses uv already knows.

Also open with what wikiq is rather than describing the repository as a
collection of tools, now that the repository is named after the program.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:53:52 -07:00
1b55acb01f rename the project from mediawiki_dump_tools to wikiq
wikiq is what the tool is called, what people type, and what they would
search for; mediawiki_dump_tools was only ever the name of the directory
it lived in. The repository is being renamed on gitea and github to
match, so rename the distribution with it.

This also settles the name to claim on PyPI. The metadata lookup that
backs --version takes the distribution name as a literal, so it changes
here too, along with the install command wikiq prints when -p legacy is
used without its extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:53:52 -07:00
b073b038af add --version and set the version to 0.3.0
wikiq had no way to report which version produced a dataset, which
matters when output columns change between runs that end up in the same
analysis. Add a --version flag, and print the version to stderr on every
run so it lands in job logs alongside the output.

0.3.0 is the first release published anywhere. The earlier 0.1.1 and
0.2.0 tags were internal markers that were never released, so the
version numbering starts being meaningful to users here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:53:52 -07:00
77f185888f run the whole suite from runtest.sh
It invoked pytest on test_wiki_diff_matcher.py alone, so it exercised 19
of the 69 tests despite its name, and it required uv, which contradicts
the README's statement that uv is optional.

Point it at test/ and use whichever python is on the path. It cds to the
repository root first, since several tests open fixture files by paths
relative to it, and passes any extra arguments through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7fff65c23d7c7a2868abccb235ec376fe093d4c1)
2026-08-13 16:53:52 -07:00
ff1f42dae5 document the optional dependencies
Explain in the installation section that a base install needs no
compiler, and give the install command for each optional feature. The
tests section said to run the suite with `uv run pytest`, which
contradicted the installation section's statement that uv is not
required; use plain pytest and note that tests for the optional features
skip when their dependency is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1c4c8dfe31cb8a8eea0dc72b7ca136b44e134c9f)
2026-08-13 16:53:52 -07:00
507cc24328 correct the test suite runtime in the README
The suite completes in about ninety seconds, not fifteen minutes. The
old figure discouraged running it as part of an ordinary change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 2bee25488b)
2026-08-13 16:53:52 -07:00
1125194093 add the project metadata a published package needs
The package declared no authors, no URLs, no classifiers and no
keywords, so a PyPI page for it would be blank and it would not turn up
in a search. Fill those in.

Contributors are listed by name only. They are the same people the
README credits, so this discloses nothing new, and publishing other
people's email addresses to PyPI is not ours to decide.

Also mirror the dev dependencies into [project.optional-dependencies].
[dependency-groups] is PEP 735, which only uv reads, so there was no way
for a pip user to install what the tests need; `pip install -e '.[dev]'`
now works. The two lists have to be kept in step by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 7b3c0f9a80e39990df90124a7c592a80d93e0d91)
2026-08-13 16:53:52 -07:00
421e9c0739 keep local files out of the source distribution
With no sdist target configured, hatchling walked the whole working tree
and included whatever it found. The resulting 8.9MB sdist carried 448
files from a virtualenv left in the repository root, plus .claude/ and
.gitmodules, while omitting the test dumps -- those are gitignored, so
the tests it did ship could not have run.

List the sdist contents explicitly. It is now 40KB of source, README and
COPYING, and both it and the wheel pass twine check. Tests run from a
clone rather than from the sdist, so test/ is deliberately excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 28406563bcdb7b2f31412e511ea30f66eb3620b5)
2026-08-13 16:45:40 -07:00
a68a596bf4 remove the stale .gitmodules
It declares a mediawiki-php-wikidiff2 submodule, but there is no gitlink
for it in the index and no such directory on disk, so `git submodule`
has nothing to act on. Leftover from 5a3e410, when wikidiff2 persistence
was first being tried; the wikidiff2 source now reaches wikiq through
pywikidiff2 instead.

It was also being swept into the source distribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 2024dcc97d4013f0b5fc9ac89bf44c5ed8a2c06d)
2026-08-13 16:45:40 -07:00
fbaafd1381 ignore the venv and local tool state
On klone the venv is created inside the repo with create_cdsc_venv, so
it otherwise shows up as untracked in every git status. Claude Code
keeps its per-project permission list in .claude/settings.local.json,
which is personal to whoever is running it, and writes it atomically via
sibling temp files, so the pattern covers those too. Only that file is
ignored, leaving room to commit a shared .claude/settings.json later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:45:40 -07:00
af8c45e8c0 rename Wikiq_Unit_Test.py to test_wikiq.py
The file was the only one in test/ using mixed case and a _Test suffix,
and pytest discovers test_*.py and *_test.py case sensitively, so it
matched neither pattern. `pytest test/` -- the command the README
documents -- quietly collected only test_resume.py and
test_wiki_diff_matcher.py, meaning the 36 tests here, including every
baseline comparison, ran only when the file was named explicitly on the
command line.

Renaming fixes discovery and matches the other test modules, rather than
teaching pytest an extra pattern to accommodate one odd name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 5a8c08e62cba5593c4b53d83f99ef389e3fd3f94)
2026-08-13 13:38:12 -07:00
b8dddf6af8 make redirect detection revision-level
The redirect_target column added in 59fea19 was page-level: the page's
state at dump time, stamped onto every revision row of the page. That
invited the wrong inference that a given revision was a redirect, and
the column was empty for dumps whose export never wrote <redirect>
elements, such as the 2023 wikitravel.org scrapes; a Swedish Wikitravel
scrape with 732 in-text redirect revisions produced no redirect signal
at all.

Remove that column and detect redirects from each revision's own text
instead. revision_is_redirect records whether the text begins with a
redirect directive and revision_redirect_target records the directive's
link target with any fragment and label stripped. #REDIRECT is
recognized on every wiki; localized keywords (e.g. OMDIRIGERING on
Swedish wikis) can be added with --redirect-aliases. Revisions with
deleted or unavailable text get nulls in both columns. The redirect-map
use case behind 59fea19 survives: the last revision's
revision_redirect_target per page reconstructs the page-level map, now
also on dumps without <redirect> elements.

Document that title and namespace are page-level identity values as of
the time of export, not historical facts about each revision.

Regenerate the test baselines for the column change. Every regenerated
file was verified to differ from its predecessor only by removing
redirect_target and adding the two new columns, with identical values
in all shared columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 40132f04dd)
2026-08-13 13:37:44 -07:00
d4111dc4f3 update README for the current tool
Describe what wikiq is and rewrite the installation, usage, and test
instructions to match the current code: pip or uv installation from the
gitea repository, the Python 3.11 requirement, output format selection
by extension, and the current option set including persistence methods,
wikitext extraction, regex matching and counting, and JSONL resume. Add
an authors section crediting the Community Data Science Collective
contributors and the earlier Python and C++ versions of the tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit d6d0aab604)
2026-08-13 13:35:33 -07:00
ceb230904e license under the GNU GPL, version 3 or later
Add the full license text as COPYING (the same text Debian ships in
common-licenses), declare the license and license file in
pyproject.toml, add copyright and permission notices to the source
files, and describe the license in the README. Also replace the
placeholder package description in pyproject.toml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 90a14cf999)
2026-08-13 13:35:33 -07:00
c31833e064 convert README to markdown
Replace the auto-converted README.md (which carried pandoc title-ref
spans and a garbled Tests heading from a malformed rst underline) with
a clean conversion of README.rst, and remove the rst version.
pyproject.toml already declares README.md as the package readme.
Content is unchanged; bringing it up to date is left for a separate
change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit fa8adc30ca)
2026-08-13 13:35:33 -07:00
15379c1c1a require Python 3.11
Python 3.9 reached end of life in October 2025, and 3.11 is where the
current ecosystem sits: it unlocks pyarrow 25 (we were held at 21, the
last release supporting 3.9), more-itertools 11, and pandas 3 for the
test suite, along with the large CPython 3.11 interpreter speedups on
exactly the kind of CPU-bound work wikiq does. Nothing in the code
needed changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:35:14 -07:00
ac8110894b resolve dependencies from PyPI instead of pinned git forks
Drop the [tool.uv.sources] pins of deltas, mwxml, and yamlconf to forks
under a personal github account:

- The deltas fork was byte-identical to upstream.
- The mwxml fork carried one patch to mwxml.map(), which wikiq does not
  call, and was missing eleven upstream commits of fixes. Require
  mwxml >= 0.3.8 for those fixes; note that 0.3.7 changed namespace
  handling to trust the dump's embedded <ns> tag rather than overriding
  it based on the title prefix.
- The yamlconf fork loosened an old pyyaml pin. yamlconf is not imported
  by wikiq and is only needed transitively via deltas, so drop the direct
  dependency. PyPI's 0.2.6 pinned pyyaml == 5.4.1, which has no wheels
  for 3.11+ and no longer builds from source; that was fixed upstream in
  0.2.7, and the project has since moved to the mediawiki-utilities
  organization.

With pywikidiff2 no longer a dependency at all, no uv-specific
configuration remains and the package installs with plain pip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:35:14 -07:00
b10a7d0130 skip tests whose optional dependency is not installed
With pywikidiff2 and mediawiki-utilities no longer installed by default,
the tests covering --diff, -p wikidiff2, and -p legacy cannot run on a
base install. Mark them so a base install reports skips rather than
failures, and keep the markers in wikiq_test_utils.py so all three test
modules share one definition of what each feature needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 59d5a9d46a04320bad5d81e0edf5ecd11f9c2a9b)
2026-08-13 13:32:08 -07:00
70b0ebcf0c make mediawiki-utilities an optional dependency
-p legacy is the only thing that uses mediawiki-utilities, and it exists
solely to reproduce numbers from research predating the mwxml and
mwtypes split. The package is a 2015 monolith that also contains
mw.database and mw.api, so it declares requests and pymysql
unconditionally: every wikiq install pulled a MySQL client and six other
packages to support one flag, building them from an sdist that has no
wheel.

Move it to a `legacy` extra. mw.lib.persistence imports nothing from
mw.database or mw.api, so nothing about -p legacy changes for people who
install the extra, and everyone else stops paying for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 66dcc6d8cea620a619fc19121b5c27a085b11991)
2026-08-13 13:32:08 -07:00
5725c66040 make pywikidiff2 an optional dependency
pywikidiff2 was imported at module scope, so every invocation of wikiq
required it -- including `wikiq --help`. It is not on PyPI, compiles a
C++ extension, and needs libthai, which made a compiler a hard
requirement for installing a tool that mostly does not need one.

Only --diff and -p wikidiff2 actually use it. Import it inside those two
code paths instead, and report what to install when it is missing rather
than failing with an ImportError traceback. The check also runs once at
startup, since both use sites sit deep in the per-revision loop and a run
can stream for hours before reaching them.

Dropping the dependency also removes the PEP 508 direct reference from
the package metadata, and with it the need for hatchling's
allow-direct-references. PyPI rejects uploads whose metadata contains a
direct URL, so this is a prerequisite for publishing wikiq there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 12325afd46670e949abf1c0e14799e212d7ff6ce)
2026-08-13 13:31:53 -07:00
6013e8c4e9 remove pyspark dependency and wikiq_spark packaging references
The spark indexing pass is moving out of this repository, and
src/wikiq_spark was never committed here: pyproject.toml declared a
wikiq-spark console script and wheel package that do not exist in the
tree, and pulled in pyspark for every install of a tool that never
imports it. The --print-schema flag and its Spark-format schema
converters remain, since they are pure Python and produce the schema
the external indexing pass consumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 392211af17)
2026-08-13 13:30:25 -07:00
015d4f9164 remove parquet output support
Nate has given up on getting wikiq to output parquet directly because it
makes too many unpredictable large memory allocations. The workflow now
outputs JSONL in a single pass and then uses spark (wikiq_spark) to index
it as parquet in a second pass.

Remove the parquet output path along with the machinery that existed only
to support it: checkpoint files, resume temp-file merging, namespace
partitioning, and file rotation (--partition-namespaces,
--max-revisions-per-file). Resume support remains for JSONL output, where
the resume point is derived from the last complete line of the output
file. Also remove the parquet tests and baseline files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 72a8851373)
2026-08-13 13:30:25 -07:00
caf604e43d document the requirements of the two skipped diff matcher tests
test_diff_consistency and test_benchmark_diff both read an uncompressed
test/dumps/ikwiki.xml that is not in the repository, so enabling them
requires decompressing the .bz2 first; test_diff_consistency also
writes debug files to the current directory. Say so where the skip
markers are.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit fde1452666)
2026-08-13 13:30:25 -07:00
04ccea4848 remove orphaned regextest.tsv baseline
No test reads this file; the regex tests use the basic_regextest and
capturegroup_regextest baselines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5fb7b5596c)
2026-08-13 13:30:25 -07:00
f4f0ed72a0 stop writing debug files from the diff matcher test helper
assert_equal_enough wrote its inputs to files named "token" and "rev"
in the current directory on every invocation, littering the repository
root whenever the test suite ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 45f0680200)
2026-08-13 13:30:25 -07:00
7fbf62e5e0 add test for --resume with --collapse-user
Resume with collapsed revision groups was untested: the resume point is
the (articleid, revid) of the last written row, which for collapsed
output is the last revision of a group, and nothing verified that the
replay reconstructs group boundaries and collapsed_revs counts
identically across the resume point. Run sailormoon with
--collapse-user, truncate the output at the midpoint, resume, and
assert the result is identical to an uninterrupted run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 6634de51e6)
2026-08-13 13:30:25 -07:00
1b3093dec0 regenerate test baselines for the redirect_target column
Commit 59fea19 added a redirect_target column to wikiq output but did
not regenerate the test baselines, leaving 14 baseline-comparison tests
failing. Regenerate the affected baselines from current output. Each
regenerated file was verified to differ from its old baseline only by
the addition of the new column: row counts and all values in shared
columns are identical.

Also add the noargs_sailormoon.jsonl baseline used by test_jsonl_noargs,
which was never committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 791be0aa56)
2026-08-13 13:30:24 -07:00
a26abe0e68 fix test helper for build_table's three-value return
build_table returns (table, reverts_column, wikitext_parser) but the
jsonl test helper still unpacked two values, so test_jsonl_noargs and
test_jsonl_tsv_equivalence failed with a ValueError before reading any
output. Also update the docstring, which still described the two-value
return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 7c27942cb0)
2026-08-13 13:30:24 -07:00
97e21e3634 only prefix namespace names onto titles that lack them
WikiqPage unconditionally prepended the namespace name to titles of
pages outside the main namespace. mwxml >= 0.3.7 keeps the namespace
prefix on the title when the dump carries <ns> tags (it previously
stripped it), which made wikiq emit double-prefixed titles like
"Category:Category:Alaska". Check for the prefix before adding it.
mwxml still strips the prefix on its fallback path for dumps without
<ns> tags, so those titles are still prefixed as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ec09641545)
2026-08-13 13:28:48 -07:00
c61eb10e47 fix regex matching on revisions with deleted text or comments
RegexPair.matchmake crashed with a TypeError when a capture-group
pattern was applied to a revision whose text or comment was deleted or
suppressed (content is None). Guard both matching paths against None,
and make the no-capture-group path emit a None column for such
revisions instead of omitting the key entirely.

This carries forward the fix Kaylea Champion and Mako Hill made on the
mako_changes-20230429 branch (7e6cd5b), which predated the rewrite.

Adds a unit test for matchmake(None) and an end-to-end test against the
ikwiki dump, which contains revisions with deleted text and comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit dbcae5c64e)
2026-08-13 13:28:48 -07:00
13 changed files with 1816 additions and 47 deletions

5
.gitignore vendored
View File

@@ -4,12 +4,11 @@
*.xml.xz *.xml.xz
*.swp *.swp
# Lockfiles
uv.lock
# JetBrains # JetBrains
/.idea /.idea
/.claude/settings.local.json*
# Python build and test output # Python build and test output
__pycache__/ __pycache__/
/test/test_output/ /test/test_output/

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "mediawiki-php-wikidiff2"]
path = mediawiki-php-wikidiff2
url = https://github.com/wikimedia/mediawiki-php-wikidiff2/

73
CHANGELOG.md Normal file
View File

@@ -0,0 +1,73 @@
# Changelog
## 0.3.0
Changes relative to what people have been running. Earlier version tags
in this repository were internal markers rather than releases, so there
is no previous release to compare against.
### Incompatible changes
- **Redirect detection is now per revision.** The page-level
`redirect_target` column has been replaced by `revision_is_redirect`
and `revision_redirect_target`, which classify each revision from its
own text. The old column reported a page's redirect status as of the
time the dump was exported and applied that single value to every
revision in the page's history, which is wrong for any page that was
turned into a redirect, or turned back, at some point in its life.
Revisions with deleted text report null in both columns rather than
false. Wikis using a localized redirect keyword can add it with
`--redirect-aliases`; `#REDIRECT` is always recognized.
Code reading `redirect_target` needs updating.
- **Parquet output has been removed**, along with the
`--partition-namespaces` and `--max-revisions-per-file` options that
only applied to it. Use JSONL, which supports `--resume`.
- **pyspark is no longer a dependency** and the `wikiq-spark` entry point
is gone. The second pass of that pipeline was never in this
repository.
- **Python 3.11 or later is required.** Python 3.9 reached end of life in
October 2025, and the floor unlocks current pyarrow, more-itertools and
pandas, along with the CPython 3.11 speedups on the CPU-bound work
wikiq does.
- **The project is now called wikiq**, rather than mediawiki_dump_tools.
### Installation
- **wikiq installs with plain `pip` and needs no C++ compiler.**
Previously every install required pywikidiff2, built from an ssh-only
git URL, so anyone outside the collective could not install the tool at
all. It is now optional and needed only for `--diff` and
`-p wikidiff2`.
- **`-p legacy` needs the new `legacy` extra.** It was the only user of
`mediawiki-utilities`, a 2015 package that also pulls in a MySQL client
and six other packages. Everyone was paying for that to support one
flag.
- **Dependencies resolve from PyPI** rather than from pinned forks under
personal accounts. Requires mwxml >= 0.3.8, which fixes eleven upstream
issues; note that mwxml 0.3.7 changed namespace handling to trust the
dump's embedded `<ns>` tag.
### Added
- `--version`, and the version is printed to stderr on every run so it
lands in job logs beside the output.
- Licensing: wikiq is GPL-3.0-or-later. See `COPYING`.
### Fixed
- Regex matching (`-RP`/`-CP`) crashed with a `TypeError` on revisions
whose text or comment was deleted or suppressed. Deleted content now
reports null.
- Page titles in non-main namespaces were prefixed twice under mwxml
0.3.7 and later, producing titles like `Category:Category:Alaska`.
- The test suite had 16 failing tests, and `pytest test/` silently
collected only half of it because the main test file matched neither of
pytest's discovery patterns. It is now `test_wikiq.py` and the suite
runs green.

View File

@@ -1,33 +1,53 @@
# mediawiki_dump_tools # wikiq
Tools for converting MediaWiki XML database dumps—the "history" dumps wikiq converts MediaWiki XML database dumps—the "history" dumps that
that include every revision of every page—into tabular datasets for include every revision of every page—into tabular datasets for research.
research. The main tool is `wikiq`, a command line program that produces It is a command line program that produces one row per revision with
one row per revision with metadata such as the page, namespace, metadata such as the page, namespace, timestamp, editor, text size, and
timestamp, editor, text size, and revert status, plus a range of revert status, plus a range of optional computed columns.
optional computed columns.
## Installation ## Installation
wikiq requires Python 3.11 or later. Install it with pip from a clone of wikiq requires Python 3.11 or later. Install it with pip from a clone of
this repository: this repository:
git clone https://gitea.communitydata.science/collective/mediawiki_dump_tools.git git clone https://gitea.communitydata.science/collective/wikiq.git
cd mediawiki_dump_tools cd wikiq
pip install . pip install .
[uv](https://docs.astral.sh/uv/) works too (`uv sync`) and is convenient
for development, but it is not required.
One dependency, `pywikidiff2`, is installed from source and compiles a
C++ extension, so a C++ compiler must be available at install time.
Wikimedia dumps are usually compressed as 7z (most common), gz, or bz2. Wikimedia dumps are usually compressed as 7z (most common), gz, or bz2.
wikiq reads these by running your system's decompression tools, so it wikiq reads these by running your system's decompression tools, so it
depends on `7za`, `zcat`, and `bzcat` for those respective formats. On depends on `7za`, `zcat`, and `bzcat` for those respective formats. On
Debian or Ubuntu, `apt install 7zip` provides `7za`; the others are Debian or Ubuntu, `apt install 7zip` provides `7za`; the others are
standard. standard.
That is everything most uses need, and none of it requires a compiler.
Two further features carry heavier dependencies, so they are kept out of
the base install.
### Diffs and wikidiff2 persistence
`--diff` and `-p wikidiff2` need
[pywikidiff2](https://gitea.communitydata.science/groceryheist/pywikidiff2),
a Python binding for MediaWiki's wikidiff2 diff engine. It is not on
PyPI and compiles a C++ extension, so it needs a C++ compiler and
libthai:
pip install 'pywikidiff2 @ git+https://gitea.communitydata.science/groceryheist/pywikidiff2.git'
The other persistence methods, including the default `-p sequence`, do
not need it.
### Legacy persistence
`-p legacy` needs `mediawiki-utilities`, which is only useful for
reproducing results from older research projects:
pip install '.[legacy]'
wikiq tells you which of these to install if you use an option that
needs one.
## Usage ## Usage
wikiq dump.xml.7z -o output/ wikiq dump.xml.7z -o output/
@@ -80,12 +100,16 @@ The most commonly useful options (`wikiq --help` describes them all):
From the repository root: From the repository root:
uv run pytest test/ pip install pytest pandas pytest-asyncio pytest-benchmark
pytest test/
Run the suite from the root—some tests open fixture files by paths Run the suite from the root—some tests open fixture files by paths
relative to it. The full suite processes several real dumps from relative to it. The full suite processes several real dumps from
`test/dumps/` and takes a couple of minutes; expected outputs live in `test/dumps/`; expected outputs live in `test/baseline_output/`.
`test/baseline_output/`.
Tests covering `--diff`, `-p wikidiff2`, and `-p legacy` skip when their
optional dependency is absent, so a base install reports skips rather
than failures.
## Authors ## Authors

View File

@@ -1,14 +1,34 @@
[project] [project]
name = "mediawiki-dump-tools" name = "wikiq"
version = "0.2.0" version = "0.3.0"
description = "Convert MediaWiki XML database dumps into tabular datasets for research" description = "Convert MediaWiki XML database dumps into tabular datasets for research"
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["COPYING"] license-files = ["COPYING"]
requires-python = ">=3.11" requires-python = ">=3.11"
authors = [
{ name = "Benjamin Mako Hill" },
{ name = "Nathan TeBlunthuis" },
{ name = "Will Beason" },
{ name = "Sohyeon Hwang" },
{ name = "Kaylea Champion" },
]
keywords = ["mediawiki", "wikipedia", "wiki", "dump", "xml", "revision", "research"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Operating System :: POSIX",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Information Analysis",
"Topic :: Text Processing :: Markup",
]
dependencies = [ dependencies = [
"deltas>=0.7.0", "deltas>=0.7.0",
"mediawiki-utilities>=0.4.18",
"more-itertools>=10.7.0", "more-itertools>=10.7.0",
"mwparserfromhell>=0.6.0", "mwparserfromhell>=0.6.0",
"mwpersistence>=0.2.4", "mwpersistence>=0.2.4",
@@ -16,10 +36,33 @@ dependencies = [
"mwtypes>=0.4.0", "mwtypes>=0.4.0",
"mwxml>=0.3.8", "mwxml>=0.3.8",
"pyarrow>=20.0.0", "pyarrow>=20.0.0",
"pywikidiff2 @ git+https://gitea.communitydata.science/groceryheist/pywikidiff2.git",
"sortedcontainers>=2.4.0", "sortedcontainers>=2.4.0",
] ]
# Two features carry dependencies that are awkward enough to install that they
# are kept out of the base install: -p legacy needs mediawiki-utilities, a 2015
# package that also pulls in a MySQL client, and --diff and -p wikidiff2 need
# pywikidiff2, which compiles a C++ extension. Everything else, including the
# default -p sequence, works with neither.
[project.optional-dependencies]
legacy = ["mediawiki-utilities>=0.4.18"]
# A wikidiff2 extra belongs here too, but pywikidiff2 is not yet published to
# PyPI, so an extra naming it could not resolve. Until it is, wikiq reports the
# git install command when --diff or -p wikidiff2 is used without it.
# Duplicated from [dependency-groups] below, which is PEP 735 and so is visible
# only to uv. This is what makes `pip install -e '.[dev]'` work.
dev = [
"pandas>=2.1.0",
"pytest>=8.4.1",
"pytest-asyncio>=1.0.0",
"pytest-benchmark>=5.1.0",
]
[project.urls]
Homepage = "https://wiki.communitydata.science/"
Repository = "https://gitea.communitydata.science/collective/wikiq"
[project.scripts] [project.scripts]
wikiq = "wikiq:main" wikiq = "wikiq:main"
@@ -30,8 +73,18 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/wikiq"] packages = ["src/wikiq"]
[tool.hatch.metadata] # Without an explicit list hatchling walks the whole working tree, which sweeps
allow-direct-references = true # in any local virtualenv and editor state while still omitting the test dumps,
# since those are gitignored. Tests run from a clone of the repository rather
# than from the sdist.
[tool.hatch.build.targets.sdist]
include = [
"src/wikiq",
"README.md",
"CHANGELOG.md",
"COPYING",
"pyproject.toml",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [

View File

@@ -1,2 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
uv run pytest test/test_wiki_diff_matcher.py --capture=tee-sys # Run the test suite from the repository root, which some tests require
# because they open fixture files by relative path.
set -euo pipefail
cd "$(dirname "$0")"
exec python -m pytest test/ "$@"

View File

@@ -32,37 +32,78 @@ from collections import deque, defaultdict
from hashlib import sha1 from hashlib import sha1
from io import TextIOWrapper from io import TextIOWrapper
from itertools import groupby from itertools import groupby
from pathlib import Path
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from typing import IO, Any, Generator, TextIO, Union from typing import IO, Any, Generator, TextIO, Union
import mwpersistence import mwpersistence
import mwreverts import mwreverts
import mwxml import mwxml
import pywikidiff2 import pyarrow as pa
import pyarrow.csv as pacsv
from deltas import SegmentMatcher, SequenceMatcher
from deltas.tokenizers import wikitext_split from deltas.tokenizers import wikitext_split
from more_itertools import peekable from more_itertools import peekable
from mwxml import Dump from mwxml import Dump
import wikiq.tables as tables import wikiq.tables as tables
from wikiq.tables import RevisionTable
from wikiq.wiki_diff_matcher import WikiDiffMatcher
from wikiq.wikitext_parser import WikitextParser
from wikiq.resume import get_resume_point from wikiq.resume import get_resume_point
from wikiq.tables import RevisionTable
from wikiq.wikitext_parser import WikitextParser
try: try:
from importlib.metadata import PackageNotFoundError from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _package_version from importlib.metadata import version as _package_version
__version__ = _package_version("mediawiki-dump-tools") __version__ = _package_version("wikiq")
except PackageNotFoundError: except PackageNotFoundError:
__version__ = "unknown" __version__ = "unknown"
TO_ENCODE = ("title", "editor") TO_ENCODE = ("title", "editor")
PERSISTENCE_RADIUS = 7 PERSISTENCE_RADIUS = 7
DIFF_TIMEOUT_MS = 60000 DIFF_TIMEOUT_MS = 60000
from pathlib import Path
import pyarrow as pa
import pyarrow.csv as pacsv # Some dependencies serve a single feature and are awkward enough to install
from deltas import SegmentMatcher, SequenceMatcher # that wikiq leaves them out of the base install. Each is imported inside the
# code path that needs it, so importing wikiq works without them and a plain
# `pip install` needs no C++ compiler. These helpers do that import and, when
# it fails, explain what to install rather than raising an ImportError.
def require_pywikidiff2(feature: str):
"""Import and return pywikidiff2, or exit explaining how to install it.
feature names the wikiq option that needs it, so the message points at
whatever the user actually asked for.
"""
try:
import pywikidiff2
except ImportError:
raise SystemExit(
f"{feature} requires pywikidiff2, which wikiq does not install by "
"default because it compiles a C++ extension.\n"
"Install it with:\n"
" pip install 'pywikidiff2 @ git+"
"https://gitea.communitydata.science/groceryheist/pywikidiff2.git'\n"
"A C++ compiler and libthai must be available. Other persistence "
"methods (-p sequence, -p segment, -p legacy) do not need it."
)
return pywikidiff2
def require_mw_persistence():
"""Import and return mw.lib.persistence, or exit explaining how to get it."""
try:
from mw.lib import persistence
except ImportError:
raise SystemExit(
"-p legacy requires mediawiki-utilities, which wikiq no longer "
"installs by default.\n"
"Install it with:\n"
" pip install 'wikiq[legacy]'\n"
"This method exists to reproduce results from older research "
"projects; -p sequence is the current equivalent."
)
return persistence
def pyarrow_type_to_spark(pa_type): def pyarrow_type_to_spark(pa_type):
@@ -769,6 +810,7 @@ class WikiqParser:
differ = None differ = None
fast_differ = None fast_differ = None
if self.diff: if self.diff:
pywikidiff2 = require_pywikidiff2("--diff")
differ = pywikidiff2.pywikidiff2( differ = pywikidiff2.pywikidiff2(
num_context_lines=1000000, num_context_lines=1000000,
max_word_level_diff_complexity=-1, max_word_level_diff_complexity=-1,
@@ -856,12 +898,14 @@ class WikiqParser:
revert_radius=PERSISTENCE_RADIUS, revert_radius=PERSISTENCE_RADIUS,
) )
elif self.persist == PersistMethod.wikidiff2: elif self.persist == PersistMethod.wikidiff2:
require_pywikidiff2("-p wikidiff2")
from wikiq.wiki_diff_matcher import WikiDiffMatcher
wikidiff_matcher = WikiDiffMatcher(tokenizer=wikitext_split) wikidiff_matcher = WikiDiffMatcher(tokenizer=wikitext_split)
persist_state = mwpersistence.DiffState( persist_state = mwpersistence.DiffState(
wikidiff_matcher, revert_radius=PERSISTENCE_RADIUS wikidiff_matcher, revert_radius=PERSISTENCE_RADIUS
) )
else: else:
from mw.lib import persistence persistence = require_mw_persistence()
persist_state = persistence.State() persist_state = persistence.State()
# Pending persistence values waiting for window to fill # Pending persistence values waiting for window to fill
@@ -1339,6 +1383,15 @@ def main():
else: else:
persist = PersistMethod.sequence persist = PersistMethod.sequence
# Check for the optional dependencies up front. Both are used deep in the
# per-revision loop, and a run can stream for hours before reaching them.
if args.diff:
require_pywikidiff2("--diff")
if persist == PersistMethod.wikidiff2:
require_pywikidiff2("-p wikidiff2")
elif persist == PersistMethod.legacy:
require_mw_persistence()
if args.namespace_filter is not None: if args.namespace_filter is not None:
namespaces = args.namespace_filter namespaces = args.namespace_filter
else: else:

View File

@@ -26,7 +26,6 @@ from mwpersistence import Token
from sortedcontainers import SortedDict from sortedcontainers import SortedDict
TOKENIZER = tokenizers.wikitext_split TOKENIZER = tokenizers.wikitext_split
import pywikidiff2
class DiffToOperationMap: class DiffToOperationMap:
@@ -348,6 +347,11 @@ class WikiDiffMatcher:
class Processor(DiffEngine.Processor): class Processor(DiffEngine.Processor):
def __init__(self, tokenizer=None): def __init__(self, tokenizer=None):
# imported here rather than at module scope so that importing
# wikiq does not require the pywikidiff2 C++ extension
from wikiq import require_pywikidiff2
pywikidiff2 = require_pywikidiff2("-p wikidiff2")
self.tokenizer = tokenizer or TOKENIZER self.tokenizer = tokenizer or TOKENIZER
self.last_tokens = [] self.last_tokens = []
self.previous_text = "" self.previous_text = ""

View File

@@ -15,6 +15,7 @@ from wikiq_test_utils import (
TEST_OUTPUT_DIR, TEST_OUTPUT_DIR,
WIKIQ, WIKIQ,
WikiqTester, WikiqTester,
requires_pywikidiff2,
) )
@@ -65,6 +66,7 @@ def test_resume():
assert_frame_equal(df_full, df_resumed) assert_frame_equal(df_full, df_resumed)
@requires_pywikidiff2
def test_resume_with_diff(): def test_resume_with_diff():
"""Test that --resume correctly computes diff values after resume. """Test that --resume correctly computes diff values after resume.
@@ -305,6 +307,7 @@ def test_resume_data_equivalence():
assert_frame_equal(df_full, df_resumed) assert_frame_equal(df_full, df_resumed)
@requires_pywikidiff2
def test_resume_with_persistence(): def test_resume_with_persistence():
"""Test that --resume correctly handles persistence state after resume. """Test that --resume correctly handles persistence state after resume.
@@ -403,6 +406,7 @@ def test_resume_corrupted_jsonl_last_line():
assert_frame_equal(df_full, df_resumed) assert_frame_equal(df_full, df_resumed)
@requires_pywikidiff2
def test_resume_diff_persistence_combined(): def test_resume_diff_persistence_combined():
"""Test that --resume correctly handles both diff and persistence state together. """Test that --resume correctly handles both diff and persistence state together.
@@ -448,6 +452,7 @@ def test_resume_diff_persistence_combined():
assert_frame_equal(df_full, df_resumed) assert_frame_equal(df_full, df_resumed)
@requires_pywikidiff2
def test_resume_mid_page(): def test_resume_mid_page():
"""Test resume from the middle of a page with many revisions. """Test resume from the middle of a page with many revisions.
@@ -501,6 +506,7 @@ def test_resume_mid_page():
assert_frame_equal(df_full, df_resumed) assert_frame_equal(df_full, df_resumed)
@requires_pywikidiff2
def test_resume_page_boundary(): def test_resume_page_boundary():
"""Test resume at the exact start of a new page. """Test resume at the exact start of a new page.

View File

@@ -1,6 +1,5 @@
from itertools import chain from itertools import chain
from functools import partial from functools import partial
import os
import re import re
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@@ -8,6 +7,10 @@ from typing import List
from deltas import Delete, Equal, Insert, wikitext_split from deltas import Delete, Equal, Insert, wikitext_split
from mwpersistence import Token from mwpersistence import Token
from wikiq.wiki_diff_matcher import WikiDiffMatcher from wikiq.wiki_diff_matcher import WikiDiffMatcher
from wikiq_test_utils import requires_pywikidiff2
# every test here drives wikidiff2 directly
pytestmark = requires_pywikidiff2
def _replace_whitespace(match): def _replace_whitespace(match):
if match.group(1): # If spaces matched (e.g., ' ') if match.group(1): # If spaces matched (e.g., ' ')
@@ -277,10 +280,6 @@ def test_paragraph_move_and_change():
assert_equal_enough(a, rev1) assert_equal_enough(a, rev1)
assert_equal_enough(b, rev2) assert_equal_enough(b, rev2)
@pytest.mark.skipif(
not os.path.exists("test/test_diff_revisions/test_infobox_from"),
reason="test_infobox_from/_to fixture files are not in the repository",
)
def test_infobox(): def test_infobox():
rev1 = open("test/test_diff_revisions/test_infobox_from").read() rev1 = open("test/test_diff_revisions/test_infobox_from").read()
rev2 = open("test/test_diff_revisions/test_infobox_to").read() rev2 = open("test/test_diff_revisions/test_infobox_to").read()

View File

@@ -23,6 +23,8 @@ from wikiq_test_utils import (
TWINPEAKS, TWINPEAKS,
WIKIQ, WIKIQ,
WikiqTester, WikiqTester,
requires_mediawiki_utilities,
requires_pywikidiff2,
) )
@@ -205,6 +207,7 @@ def test_collapse_user():
baseline = pd.read_table(tester.baseline_file) baseline = pd.read_table(tester.baseline_file)
assert_frame_equal(test, baseline, check_like=True) assert_frame_equal(test, baseline, check_like=True)
@requires_pywikidiff2
def test_pwr_wikidiff2(): def test_pwr_wikidiff2():
tester = WikiqTester(SAILORMOON, "persistence_wikidiff2", in_compression="7z") tester = WikiqTester(SAILORMOON, "persistence_wikidiff2", in_compression="7z")
@@ -229,6 +232,7 @@ def test_pwr_segment():
baseline = pd.read_table(tester.baseline_file) baseline = pd.read_table(tester.baseline_file)
assert_frame_equal(test, baseline, check_like=True) assert_frame_equal(test, baseline, check_like=True)
@requires_mediawiki_utilities
def test_pwr_legacy(): def test_pwr_legacy():
tester = WikiqTester(SAILORMOON, "persistence_legacy", in_compression="7z") tester = WikiqTester(SAILORMOON, "persistence_legacy", in_compression="7z")
@@ -255,6 +259,7 @@ def test_pwr():
test = test.reindex(columns=sorted(test.columns)) test = test.reindex(columns=sorted(test.columns))
assert_frame_equal(test, baseline, check_like=True) assert_frame_equal(test, baseline, check_like=True)
@requires_pywikidiff2
def test_diff(): def test_diff():
tester = WikiqTester(SAILORMOON, "diff", in_compression="7z", out_format='jsonl') tester = WikiqTester(SAILORMOON, "diff", in_compression="7z", out_format='jsonl')
@@ -268,6 +273,7 @@ def test_diff():
assert "diff_timeout" in test.columns, "diff_timeout column should exist" assert "diff_timeout" in test.columns, "diff_timeout column should exist"
assert len(test) > 0, "Should have output rows" assert len(test) > 0, "Should have output rows"
@requires_pywikidiff2
def test_diff_plus_pwr(): def test_diff_plus_pwr():
tester = WikiqTester(SAILORMOON, "diff_pwr", in_compression="7z", out_format='jsonl') tester = WikiqTester(SAILORMOON, "diff_pwr", in_compression="7z", out_format='jsonl')
@@ -281,6 +287,7 @@ def test_diff_plus_pwr():
assert "token_revs" in test.columns, "token_revs column should exist" assert "token_revs" in test.columns, "token_revs column should exist"
assert len(test) > 0, "Should have output rows" assert len(test) > 0, "Should have output rows"
@requires_pywikidiff2
def test_text(): def test_text():
tester = WikiqTester(SAILORMOON, "text", in_compression="7z", out_format='jsonl') tester = WikiqTester(SAILORMOON, "text", in_compression="7z", out_format='jsonl')

View File

@@ -1,8 +1,27 @@
import importlib.util
import os import os
import shutil import shutil
import subprocess import subprocess
from typing import Final, Union from typing import Final, Union
import pytest
def _installed(module: str) -> bool:
return importlib.util.find_spec(module) is not None
# wikiq's two optional dependencies. Tests exercising --diff, -p wikidiff2, or
# -p legacy cannot run on a base install, so they skip rather than fail.
requires_pywikidiff2 = pytest.mark.skipif(
not _installed("pywikidiff2"),
reason="needs the optional pywikidiff2 extension (--diff, -p wikidiff2)",
)
requires_mediawiki_utilities = pytest.mark.skipif(
not _installed("mw"),
reason="needs the optional mediawiki-utilities package (-p legacy)",
)
TEST_DIR: Final[str] = os.path.dirname(os.path.realpath(__file__)) TEST_DIR: Final[str] = os.path.dirname(os.path.realpath(__file__))
WIKIQ: Final[str] = os.path.join(os.path.join(TEST_DIR, ".."), "src/wikiq/__init__.py") WIKIQ: Final[str] = os.path.join(os.path.join(TEST_DIR, ".."), "src/wikiq/__init__.py")
TEST_OUTPUT_DIR: Final[str] = os.path.join(TEST_DIR, "test_output") TEST_OUTPUT_DIR: Final[str] = os.path.join(TEST_DIR, "test_output")

1531
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff