30 Commits

Author SHA1 Message Date
c49fa5af3c Merge branch 'mako_changes-20260806' into content-sizes 2026-08-13 08:52:15 -07:00
dfd2dbe20f resolve yamlconf from PyPI
yamlconf 0.2.7 removes the pyyaml == 5.4.1 pin that made 0.2.6
unbuildable on Python 3.11+, so the interim reference to the fork
carrying that fix is no longer needed. yamlconf is not a direct
dependency of wikiq—it returns to being resolved transitively via
deltas. The upstream repository has also moved to the
mediawiki-utilities organization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 08:40:06 -07:00
851f070c8f Merge branch 'mako_changes-20260806' into content-sizes 2026-08-08 14:55:06 -07:00
fde1452666 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>
2026-08-08 14:52:59 -07:00
c6117a7182 Merge remote-tracking branch 'gitea/jsonl-output' into mako_changes-20260806 2026-08-08 14:42:49 -07:00
0b1557efc5 note that installs are copies and point CDSC users to the wiki
pip install . copies the source, so a branch switch leaves the installed
console script running the old code. Say so, since that is easy to miss.

Setup on klone differs enough that duplicating it here would go stale;
link to the collective's wiki rather than restating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:06:16 -07:00
aedd4b3086 Merge branch 'mako_changes-20260806' into content-sizes 2026-08-08 12:01:20 -07:00
2bee25488b 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>
2026-08-08 11:45:04 -07:00
702101bfe2 ignore the .venv directory
On klone the venv is created inside the repo with create_cdsc_venv, so
it otherwise shows up as untracked in every git status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:39:14 -07:00
1700561b07 Merge branch 'mako_changes-20260806' into content-sizes
Brings in revision-level redirect detection and its regenerated
baselines. Conflicts were the expected adjacent insertions where both
branches extended the same seams (build_table and WikiqParser
signatures, the argparse block, the build_table call sites, and the
test file); resolved by keeping both branches' additions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:52:31 -07:00
40132f04dd 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>
2026-08-06 23:41:57 -07:00
3d6bf34c8e add --content-sizes: decompose revision content into prose and structured characters
A single mwparserfromhell tree walk per revision emits two nullable
int64 columns. prose_chars counts rendered content outside any template
or tag: text nodes, wikilink labels (or targets when unlabeled),
external link labels, and heading titles. structured_chars counts the
same kinds of content inside template parameter values or tag contents,
with each character assigned by its nearest enclosing container. Markup
syntax, template and tag names, parameter names, attributes, bare URLs,
and HTML comments count in neither, so syntax overhead is derivable as
the total size minus the two columns.

Deleted revisions and parse timeouts yield nulls through the same
machinery as the other parser-based columns and do not change which
revisions are processed. Note that mwparserfromhell parses bold and
italic markup as tags, so emphasized text counts as structured.

Includes hand-computed fixture tests for the counting rules and an
end-to-end test on a dump with deleted revisions verifying nulls and
the invariant prose_chars + structured_chars <= revision size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:09:29 -07:00
c1ee926211 add --version and bump to 0.2.0
Resolve the package version from installed metadata, report it via a
--version flag, and print it at the start of each run so output logs
record which wikiq produced them. Bump the version to 0.2.0 for the
changes on this branch: regex match counting, the removal of parquet
output, the Python 3.11 floor, and the dependency cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:33:55 -07:00
d6d0aab604 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>
2026-08-06 16:30:21 -07:00
90a14cf999 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>
2026-08-06 16:29:48 -07:00
5fb7b5596c 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>
2026-08-06 16:23:52 -07:00
fa8adc30ca 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>
2026-08-06 16:23:52 -07:00
45f0680200 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>
2026-08-06 16:22:39 -07:00
0ed0f57dbb 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.

Raising the floor exposed that PyPI's yamlconf 0.2.6 (a transitive
dependency via deltas) pins pyyaml == 5.4.1, which has no wheels for
3.11+ and no longer builds from source under current setuptools. Add a
PEP 508 reference to the fork that loosens the pin; it can be dropped
when https://github.com/halfak/yamlconf/pull/10 is merged and released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:17:14 -07:00
14886106ed resolve dependencies from PyPI instead of pinned github 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 in requirements.txt.
  yamlconf is not imported by wikiq and is only needed transitively by
  deltas, so drop the direct dependency; the PyPI release installs fine,
  though its pyyaml pin is old (worth fixing upstream).

With pywikidiff2 already declared as a PEP 508 direct https reference,
no uv-specific configuration remains and the package installs with pip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:05:44 -07:00
ec09641545 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>
2026-08-06 16:05:44 -07:00
3ab988d6eb declare pywikidiff2 as a PEP 508 direct reference over https
Move pywikidiff2 from a [tool.uv.sources] entry to a direct git URL in
the dependency list, and switch it from ssh to anonymous https. The ssh
URL required an account on the gitea instance just to install the
package. A PEP 508 direct reference is understood by pip and every other
standards-compliant installer, not just uv, so this is a step toward
making uv an optional convenience rather than a requirement for working
with this repository.

Hatchling rejects direct references by default because they are not
allowed in packages published to PyPI (this one is not); set
tool.hatch.metadata.allow-direct-references to permit them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:03:41 -07:00
392211af17 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>
2026-08-06 15:50:50 -07:00
0b6e0fbc70 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>
2026-08-06 15:47:38 -07:00
6634de51e6 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>
2026-08-06 15:47:28 -07:00
83b7fc0210 skip test_infobox when its fixture files are absent
test_infobox reads test/test_diff_revisions/test_infobox_from and
test_infobox_to, which were never committed to the repository, so the
test always failed with FileNotFoundError. Skip it when the fixtures
are missing so it runs again if they are ever added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:34:48 -07:00
791be0aa56 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>
2026-08-06 15:34:38 -07:00
7c27942cb0 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>
2026-08-06 15:33:34 -07:00
72a8851373 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>
2026-08-06 15:31:48 -07:00
dbcae5c64e 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>
2026-08-06 14:20:02 -07:00
15 changed files with 248 additions and 1815 deletions

5
.gitignore vendored
View File

@@ -4,11 +4,12 @@
*.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 Normal file
View File

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

View File

@@ -1,73 +0,0 @@
# 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,52 +1,41 @@
# wikiq # mediawiki_dump_tools
wikiq converts MediaWiki XML database dumps—the "history" dumps that Tools for converting MediaWiki XML database dumps—the "history" dumps
include every revision of every page—into tabular datasets for research. that include every revision of every page—into tabular datasets for
It is a command line program that produces one row per revision with research. The main tool is `wikiq`, a command line program that produces
metadata such as the page, namespace, timestamp, editor, text size, and one row per revision with metadata such as the page, namespace,
revert status, plus a range of optional computed columns. timestamp, editor, text size, and revert status, plus a range of
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/wikiq.git git clone https://gitea.communitydata.science/collective/mediawiki_dump_tools.git
cd wikiq cd mediawiki_dump_tools
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. Note that `pip install .` copies the source into the environment, so
Two further features carry heavier dependencies, so they are kept out of rerun it after switching branches or editing the code.
the base install.
### Diffs and wikidiff2 persistence Members of the Community Data Science Collective installing on the klone
cluster should follow the collective's own instructions instead, since
`--diff` and `-p wikidiff2` need the Python environment and its dependencies are set up differently
[pywikidiff2](https://gitea.communitydata.science/groceryheist/pywikidiff2), there. See [CommunityData:Hyak software
a Python binding for MediaWiki's wikidiff2 diff engine. It is not on installation](https://wiki.communitydata.science/CommunityData:Hyak_software_installation).
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
@@ -85,6 +74,10 @@ The most commonly useful options (`wikiq --help` describes them all):
- `--external-links`, `--citations`, `--wikilinks`, `--templates`, and - `--external-links`, `--citations`, `--wikilinks`, `--templates`, and
`--headings` parse each revision's wikitext and add a column with the `--headings` parse each revision's wikitext and add a column with the
extracted elements. extracted elements.
- `--content-sizes` counts each revision's rendered content characters
in two columns: `prose_chars` (content outside templates and tags) and
`structured_chars` (template parameter values and tag inner text).
Markup syntax and comments count in neither.
- `-RP REGEX -RPl LABEL` searches revision text for a regular expression - `-RP REGEX -RPl LABEL` searches revision text for a regular expression
and reports the matches in a column named by the label; repeat the and reports the matches in a column named by the label; repeat the
pair for multiple patterns. `-CP`/`-CPl` do the same for edit pair for multiple patterns. `-CP`/`-CPl` do the same for edit
@@ -100,16 +93,12 @@ The most commonly useful options (`wikiq --help` describes them all):
From the repository root: From the repository root:
pip install pytest pandas pytest-asyncio pytest-benchmark uv run pytest test/
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/`; expected outputs live in `test/baseline_output/`. `test/dumps/` and takes a couple of minutes; expected outputs live in
`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,34 +1,14 @@
[project] [project]
name = "wikiq" name = "mediawiki-dump-tools"
version = "0.3.0" version = "0.2.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",
@@ -36,33 +16,10 @@ 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"
@@ -73,18 +30,8 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/wikiq"] packages = ["src/wikiq"]
# Without an explicit list hatchling walks the whole working tree, which sweeps [tool.hatch.metadata]
# in any local virtualenv and editor state while still omitting the test dumps, allow-direct-references = true
# 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,6 +1,2 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run the test suite from the repository root, which some tests require uv run pytest test/test_wiki_diff_matcher.py --capture=tee-sys
# because they open fixture files by relative path.
set -euo pipefail
cd "$(dirname "$0")"
exec python -m pytest test/ "$@"

View File

@@ -32,78 +32,37 @@ 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 pyarrow as pa import pywikidiff2
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.resume import get_resume_point
from wikiq.tables import RevisionTable from wikiq.tables import RevisionTable
from wikiq.wiki_diff_matcher import WikiDiffMatcher
from wikiq.wikitext_parser import WikitextParser from wikiq.wikitext_parser import WikitextParser
from wikiq.resume import get_resume_point
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("wikiq") __version__ = _package_version("mediawiki-dump-tools")
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
# Some dependencies serve a single feature and are awkward enough to install import pyarrow.csv as pacsv
# that wikiq leaves them out of the base install. Each is imported inside the from deltas import SegmentMatcher, SequenceMatcher
# 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):
@@ -174,6 +133,7 @@ def build_table(
wikilinks: bool = False, wikilinks: bool = False,
templates: bool = False, templates: bool = False,
headings: bool = False, headings: bool = False,
content_sizes: bool = False,
redirect_aliases: Union[list[str], None] = None, redirect_aliases: Union[list[str], None] = None,
): ):
"""Build the RevisionTable with appropriate columns based on flags. """Build the RevisionTable with appropriate columns based on flags.
@@ -213,7 +173,7 @@ def build_table(
table.columns.append(tables.RevisionCollapsed()) table.columns.append(tables.RevisionCollapsed())
wikitext_parser = None wikitext_parser = None
if external_links or citations or wikilinks or templates or headings: if external_links or citations or wikilinks or templates or headings or content_sizes:
wikitext_parser = WikitextParser() wikitext_parser = WikitextParser()
if external_links: if external_links:
@@ -231,6 +191,10 @@ def build_table(
if headings: if headings:
table.columns.append(tables.RevisionHeadings(wikitext_parser)) table.columns.append(tables.RevisionHeadings(wikitext_parser))
if content_sizes:
table.columns.append(tables.RevisionProseChars(wikitext_parser))
table.columns.append(tables.RevisionStructuredChars(wikitext_parser))
table.columns.append(tables.RevisionParserTimeout(wikitext_parser)) table.columns.append(tables.RevisionParserTimeout(wikitext_parser))
return table, reverts_column, wikitext_parser return table, reverts_column, wikitext_parser
@@ -605,6 +569,7 @@ class WikiqParser:
wikilinks: bool = False, wikilinks: bool = False,
templates: bool = False, templates: bool = False,
headings: bool = False, headings: bool = False,
content_sizes: bool = False,
redirect_aliases: Union[list[str], None] = None, redirect_aliases: Union[list[str], None] = None,
time_limit_seconds: Union[float, None] = None, time_limit_seconds: Union[float, None] = None,
input_filename: Union[str, None] = None, input_filename: Union[str, None] = None,
@@ -631,6 +596,7 @@ class WikiqParser:
self.wikilinks = wikilinks self.wikilinks = wikilinks
self.templates = templates self.templates = templates
self.headings = headings self.headings = headings
self.content_sizes = content_sizes
self.redirect_aliases = redirect_aliases self.redirect_aliases = redirect_aliases
self.shutdown_requested = False self.shutdown_requested = False
self.time_limit_seconds = time_limit_seconds self.time_limit_seconds = time_limit_seconds
@@ -757,6 +723,7 @@ class WikiqParser:
wikilinks=self.wikilinks, wikilinks=self.wikilinks,
templates=self.templates, templates=self.templates,
headings=self.headings, headings=self.headings,
content_sizes=self.content_sizes,
redirect_aliases=self.redirect_aliases, redirect_aliases=self.redirect_aliases,
) )
@@ -810,7 +777,6 @@ 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,
@@ -898,14 +864,12 @@ 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:
persistence = require_mw_persistence() from mw.lib import 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
@@ -1329,6 +1293,14 @@ def main():
help="Extract section headings from each revision.", help="Extract section headings from each revision.",
) )
parser.add_argument(
"--content-sizes",
dest="content_sizes",
action="store_true",
default=False,
help="Count characters of rendered content in each revision, split into prose_chars (outside templates and tags) and structured_chars (template parameter values and tag inner text). Markup syntax and comments count in neither.",
)
parser.add_argument( parser.add_argument(
"--redirect-aliases", "--redirect-aliases",
dest="redirect_aliases", dest="redirect_aliases",
@@ -1383,15 +1355,6 @@ 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:
@@ -1420,6 +1383,7 @@ def main():
wikilinks=args.wikilinks, wikilinks=args.wikilinks,
templates=args.templates, templates=args.templates,
headings=args.headings, headings=args.headings,
content_sizes=args.content_sizes,
redirect_aliases=redirect_aliases, redirect_aliases=redirect_aliases,
) )
schema = build_schema( schema = build_schema(
@@ -1503,6 +1467,7 @@ def main():
wikilinks=args.wikilinks, wikilinks=args.wikilinks,
templates=args.templates, templates=args.templates,
headings=args.headings, headings=args.headings,
content_sizes=args.content_sizes,
redirect_aliases=redirect_aliases, redirect_aliases=redirect_aliases,
time_limit_seconds=time_limit_seconds, time_limit_seconds=time_limit_seconds,
input_filename=filename, input_filename=filename,
@@ -1560,6 +1525,7 @@ def main():
wikilinks=args.wikilinks, wikilinks=args.wikilinks,
templates=args.templates, templates=args.templates,
headings=args.headings, headings=args.headings,
content_sizes=args.content_sizes,
redirect_aliases=redirect_aliases, redirect_aliases=redirect_aliases,
time_limit_seconds=time_limit_seconds, time_limit_seconds=time_limit_seconds,
) )

View File

@@ -384,6 +384,40 @@ class RevisionHeadings(RevisionField[Union[list[dict], None]]):
return self.wikitext_parser.extract_headings(revision.text) return self.wikitext_parser.extract_headings(revision.text)
class RevisionProseChars(RevisionField[Union[int, None]]):
"""Count characters of rendered content outside templates and tags."""
field = pa.field("prose_chars", pa.int64(), nullable=True)
def __init__(self, wikitext_parser: "WikitextParser"):
super().__init__()
self.wikitext_parser = wikitext_parser
def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
revision = revisions[-1]
if revision.deleted.text:
return None
sizes = self.wikitext_parser.content_sizes(revision.text)
return None if sizes is None else sizes[0]
class RevisionStructuredChars(RevisionField[Union[int, None]]):
"""Count characters of rendered content inside templates and tags."""
field = pa.field("structured_chars", pa.int64(), nullable=True)
def __init__(self, wikitext_parser: "WikitextParser"):
super().__init__()
self.wikitext_parser = wikitext_parser
def extract(self, page: mwtypes.Page, revisions: list[mwxml.Revision]) -> Union[int, None]:
revision = revisions[-1]
if revision.deleted.text:
return None
sizes = self.wikitext_parser.content_sizes(revision.text)
return None if sizes is None else sizes[1]
class RevisionParserTimeout(RevisionField[bool]): class RevisionParserTimeout(RevisionField[bool]):
"""Track whether the wikitext parser timed out for this revision.""" """Track whether the wikitext parser timed out for this revision."""

View File

@@ -26,6 +26,7 @@ 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:
@@ -347,11 +348,6 @@ 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

@@ -20,6 +20,17 @@ from __future__ import annotations
import signal import signal
import mwparserfromhell import mwparserfromhell
from mwparserfromhell.nodes import (
Argument,
Comment,
ExternalLink,
Heading,
HTMLEntity,
Tag,
Template,
Text,
Wikilink,
)
PARSER_TIMEOUT = 60 # seconds PARSER_TIMEOUT = 60 # seconds
@@ -38,6 +49,8 @@ class WikitextParser:
self._cached_text: str | None = None self._cached_text: str | None = None
self._cached_wikicode = None self._cached_wikicode = None
self.last_parse_timed_out: bool = False self.last_parse_timed_out: bool = False
self._sizes_text: str | None = None
self._cached_sizes: tuple[int, int] | None = None
def _timeout_handler(self, signum, frame): def _timeout_handler(self, signum, frame):
raise TimeoutError("mwparserfromhell parse exceeded timeout") raise TimeoutError("mwparserfromhell parse exceeded timeout")
@@ -146,6 +159,71 @@ class WikitextParser:
except Exception: except Exception:
return None return None
def _count_content(self, wikicode, structured: bool, counts: list[int]) -> None:
"""Walk a wikicode tree adding rendered content lengths to counts.
counts is a two-element list of [prose, structured] character
counts. structured is True once any enclosing template parameter
value or tag contents has been entered.
"""
for node in wikicode.nodes:
if isinstance(node, Text):
counts[1 if structured else 0] += len(node.value)
elif isinstance(node, HTMLEntity):
counts[1 if structured else 0] += len(node.normalize())
elif isinstance(node, Comment):
continue
elif isinstance(node, Template):
# parameter values are structured content; names and
# delimiters are not counted
for param in node.params:
self._count_content(param.value, True, counts)
elif isinstance(node, Tag):
# tag inner text is structured content; tag names and
# attributes are not counted
if node.contents is not None:
self._count_content(node.contents, True, counts)
elif isinstance(node, Wikilink):
# only the rendered text counts: the label if present,
# otherwise the target
rendered = node.text if node.text is not None else node.title
self._count_content(rendered, structured, counts)
elif isinstance(node, ExternalLink):
# only the label renders as content; bare URLs count as
# neither
if node.title is not None:
self._count_content(node.title, structured, counts)
elif isinstance(node, Heading):
self._count_content(node.title, structured, counts)
elif isinstance(node, Argument):
if node.default is not None:
self._count_content(node.default, structured, counts)
def content_sizes(self, text: str | None) -> tuple[int, int] | None:
"""Count characters of substantive content outside and inside
structured elements.
Returns (prose_chars, structured_chars). Prose is the rendered text
not enclosed by any template or tag; structured is the same kinds of
content inside template parameter values or tag contents. Markup
syntax, template and tag names, parameter names, attributes, bare
URLs, and comments count in neither.
"""
if text is None:
return None
if text == self._sizes_text:
return self._cached_sizes
try:
wikicode = self._get_wikicode(text)
counts = [0, 0]
self._count_content(wikicode, False, counts)
result = (counts[0], counts[1])
except Exception:
result = None
self._sizes_text = text
self._cached_sizes = result
return result
def extract_headings(self, text: str | None) -> list[dict] | None: def extract_headings(self, text: str | None) -> list[dict] | None:
"""Extract all section headings with their levels.""" """Extract all section headings with their levels."""
if text is None: if text is None:

View File

@@ -12,7 +12,7 @@ import pytest
from pandas import DataFrame from pandas import DataFrame
from pandas.testing import assert_frame_equal, assert_series_equal from pandas.testing import assert_frame_equal, assert_series_equal
from wikiq import build_table, build_schema, RegexPair from wikiq import build_table, build_schema, RegexPair, WikitextParser
from wikiq_test_utils import ( from wikiq_test_utils import (
BASELINE_DIR, BASELINE_DIR,
IKWIKI, IKWIKI,
@@ -23,8 +23,6 @@ from wikiq_test_utils import (
TWINPEAKS, TWINPEAKS,
WIKIQ, WIKIQ,
WikiqTester, WikiqTester,
requires_mediawiki_utilities,
requires_pywikidiff2,
) )
@@ -207,7 +205,6 @@ 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")
@@ -232,7 +229,6 @@ 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")
@@ -259,7 +255,6 @@ 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')
@@ -273,7 +268,6 @@ 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')
@@ -287,7 +281,6 @@ 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')
@@ -476,6 +469,64 @@ def test_regex_count_e2e():
expected = len(re.findall(r"\b[Cc]hevalier\b", comment)) if comment is not None else 0 expected = len(re.findall(r"\b[Cc]hevalier\b", comment)) if comment is not None else 0
assert row["chev"] == expected, f"revid {row['revid']}: {row['chev']} != {expected}" assert row["chev"] == expected, f"revid {row['revid']}: {row['chev']} != {expected}"
def test_content_sizes():
# hand-computed fixtures: (text, prose_chars, structured_chars)
cases = [
# pure prose
("Hello world.", 12, 0),
# prose plus a listing-style template: parameter values count as
# structured, parameter names and braces do not
# prose: "Visit us. " = 10; structured: Hotel(5) + 555(3) + Nice place(10) = 18
("Visit us. {{listing|name=Hotel|phone=555|description=Nice place}}", 10, 18),
# tag inner text counts, attributes do not
# prose: "Before "(7) + " after"(6) = 13; structured: "inner text" = 10
('Before <span class="x">inner text</span> after', 13, 10),
# nesting: a template inside a template argument stays structured
# structured: deep(4) + " shallow"(8) = 12
("{{outer|a={{inner|b=deep}} shallow}}", 0, 12),
# a labeled wikilink in prose counts its label; a wikilink inside a
# template argument counts its rendered text as structured
# prose: "See "(4) + "the main page"(13) + " and "(5) = 22; structured: Help(4)
("See [[Main Page|the main page]] and {{box|link=[[Help]]}}", 22, 4),
# HTML comments count in neither column
# prose: "Text "(5) + " more"(5) = 10
("Text <!-- hidden --> more", 10, 0),
]
parser = WikitextParser()
for text, prose, structured in cases:
result = parser.content_sizes(text)
assert result == (prose, structured), f"{text!r}: got {result}, want {(prose, structured)}"
assert result[0] + result[1] <= len(text), f"{text!r}: content exceeds total size"
assert parser.content_sizes(None) is None
def test_content_sizes_e2e():
# the ikwiki dump contains revisions with deleted text, which must get
# null content sizes; all other revisions must satisfy the invariant
# prose_chars + structured_chars <= len(text)
tester = WikiqTester(IKWIKI, "content_sizes")
try:
tester.call_wikiq("--content-sizes", "--text")
except subprocess.CalledProcessError as exc:
pytest.fail(exc.stderr.decode("utf8"))
test = pd.read_table(tester.output)
assert "prose_chars" in test.columns
assert "structured_chars" in test.columns
deleted = test[test["deleted"]]
assert len(deleted) > 0
assert deleted["prose_chars"].isna().all()
assert deleted["structured_chars"].isna().all()
present = test[~test["deleted"] & test["text"].notna() & test["prose_chars"].notna()]
assert len(present) > 0
total = present["prose_chars"] + present["structured_chars"]
assert (total <= present["text"].str.len()).all()
def test_redirect_detection(): def test_redirect_detection():
from wikiq.tables import RedirectDetector from wikiq.tables import RedirectDetector

View File

@@ -15,7 +15,6 @@ from wikiq_test_utils import (
TEST_OUTPUT_DIR, TEST_OUTPUT_DIR,
WIKIQ, WIKIQ,
WikiqTester, WikiqTester,
requires_pywikidiff2,
) )
@@ -66,7 +65,6 @@ 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.
@@ -307,7 +305,6 @@ 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.
@@ -406,7 +403,6 @@ 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.
@@ -452,7 +448,6 @@ 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.
@@ -506,7 +501,6 @@ 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,5 +1,6 @@
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
@@ -7,10 +8,6 @@ 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., ' ')
@@ -280,6 +277,10 @@ 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

@@ -1,27 +1,8 @@
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

File diff suppressed because it is too large Load Diff