24 Commits

Author SHA1 Message Date
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
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
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
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
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
13 changed files with 158 additions and 1842 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,53 +1,33 @@
# 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.
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/
@@ -88,7 +68,8 @@ The most commonly useful options (`wikiq --help` describes them all):
- `-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
summaries. summaries. Adding `-RPc` or `-CPc` reports the number of matches
instead of the matched text.
- `--resume` continues an interrupted run from the last complete line of - `--resume` continues an interrupted run from the last complete line of
an existing JSONL output file. Combined with `--time-limit HOURS`, an existing JSONL output file. Combined with `--time-limit HOURS`,
this supports processing very large dumps in bounded chunks. this supports processing very large dumps in bounded chunks.
@@ -99,16 +80,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):
@@ -274,10 +233,10 @@ def build_schema(
return schema return schema
def make_regex_pairs(patterns, labels) -> list: def make_regex_pairs(patterns, labels, count_only=False) -> list:
"""Create RegexPair objects from patterns and labels.""" """Create RegexPair objects from patterns and labels."""
if (patterns is not None and labels is not None) and (len(patterns) == len(labels)): if (patterns is not None and labels is not None) and (len(patterns) == len(labels)):
return [RegexPair(pattern, label) for pattern, label in zip(patterns, labels)] return [RegexPair(pattern, label, count_only) for pattern, label in zip(patterns, labels)]
elif patterns is None and labels is None: elif patterns is None and labels is None:
return [] return []
else: else:
@@ -502,21 +461,23 @@ If the pattern does not include a capture group, then only one output column wil
class RegexPair(object): class RegexPair(object):
def __init__(self, pattern, label): def __init__(self, pattern, label, count_only=False):
self.pattern = re.compile(pattern) self.pattern = re.compile(pattern)
self.label = label self.label = label
self.count_only = count_only
self.has_groups = bool(self.pattern.groupindex) self.has_groups = bool(self.pattern.groupindex)
if self.has_groups: if self.has_groups:
self.capture_groups = list(self.pattern.groupindex.keys()) self.capture_groups = list(self.pattern.groupindex.keys())
def get_pyarrow_fields(self): def get_pyarrow_fields(self):
value_type = pa.int64() if self.count_only else pa.string()
if self.has_groups: if self.has_groups:
fields = [ fields = [
pa.field(self._make_key(cap_group), pa.string()) pa.field(self._make_key(cap_group), value_type)
for cap_group in self.capture_groups for cap_group in self.capture_groups
] ]
else: else:
fields = [pa.field(self.label, pa.string())] fields = [pa.field(self.label, value_type)]
return fields return fields
@@ -529,39 +490,50 @@ class RegexPair(object):
if self.has_groups: if self.has_groups:
# if there are matches of some sort in this revision content, fill the lists for each cap_group # if there are matches of some sort in this revision content, fill the lists for each cap_group
# content can be None when the text or comment was deleted/suppressed # content can be None when the text or comment was deleted/suppressed
if content is not None and self.pattern.search(content) is not None: if content is not None and (matchobjects := list(self.pattern.finditer(content))):
m = self.pattern.finditer(content)
matchobjects = list(m)
for cap_group in self.capture_groups: for cap_group in self.capture_groups:
key = self._make_key(cap_group) key = self._make_key(cap_group)
temp_list = [] temp_list = []
for match in matchobjects: for match in matchobjects:
# we only want to add the match for the capture group if the match is not None # we only want to add the match for the capture group if the match is not None
if match.group(cap_group) is not None: if (group := match.group(cap_group)) is not None:
temp_list.append(match.group(cap_group)) temp_list.append(group)
# if temp_list of matches is empty just make that column None # if temp_list of matches is empty just make that column None (0 in count mode)
if len(temp_list) == 0: if len(temp_list) == 0:
temp_dict[key] = None if self.count_only:
temp_dict[key] = 0
else:
temp_dict[key] = None
# else we put in the list we made in the for-loop above # else we put in the list we made in the for-loop above
else: else:
temp_dict[key] = ", ".join(temp_list) if self.count_only:
temp_dict[key] = len(temp_list)
else:
temp_dict[key] = ", ".join(temp_list)
# there are no matches at all in this revision content, we default values to None # there are no matches at all in this revision content, we default values to None (0 in count mode)
else: else:
for cap_group in self.capture_groups: for cap_group in self.capture_groups:
key = self._make_key(cap_group) key = self._make_key(cap_group)
temp_dict[key] = None if self.count_only:
temp_dict[key] = 0
else:
temp_dict[key] = None
# there are no capture groups, we just search for all the matches of the regex # there are no capture groups, we just search for all the matches of the regex
else: else:
# given that there are matches to be made # given that there are matches to be made
if content is not None and self.pattern.search(content) is not None: if content is not None and (matches := self.pattern.findall(content)):
m = self.pattern.findall(content) if self.count_only:
temp_dict[self.label] = ", ".join(m) temp_dict[self.label] = len(matches)
else:
temp_dict[self.label] = ", ".join(matches)
else: else:
temp_dict[self.label] = None if self.count_only:
temp_dict[self.label] = 0
else:
temp_dict[self.label] = None
return temp_dict return temp_dict
@@ -575,6 +547,8 @@ class WikiqParser:
regex_match_comment: list[str], regex_match_comment: list[str],
regex_revision_label: list[str], regex_revision_label: list[str],
regex_comment_label: list[str], regex_comment_label: list[str],
regex_revision_output_count: bool = False,
regex_comment_output_count: bool = False,
text: bool = False, text: bool = False,
diff: bool = False, diff: bool = False,
collapse_user: bool = False, collapse_user: bool = False,
@@ -626,10 +600,10 @@ class WikiqParser:
self.regex_schemas = [] self.regex_schemas = []
self.regex_revision_pairs: list[RegexPair] = self.make_matchmake_pairs( self.regex_revision_pairs: list[RegexPair] = self.make_matchmake_pairs(
regex_match_revision, regex_revision_label regex_match_revision, regex_revision_label, regex_revision_output_count
) )
self.regex_comment_pairs: list[RegexPair] = self.make_matchmake_pairs( self.regex_comment_pairs: list[RegexPair] = self.make_matchmake_pairs(
regex_match_comment, regex_comment_label regex_match_comment, regex_comment_label, regex_comment_output_count
) )
# Initialize output # Initialize output
@@ -672,13 +646,13 @@ class WikiqParser:
if timer is not None: if timer is not None:
timer.cancel() timer.cancel()
def make_matchmake_pairs(self, patterns, labels) -> list[RegexPair]: def make_matchmake_pairs(self, patterns, labels, count_only=False) -> list[RegexPair]:
if (patterns is not None and labels is not None) and ( if (patterns is not None and labels is not None) and (
len(patterns) == len(labels) len(patterns) == len(labels)
): ):
result: list[RegexPair] = [] result: list[RegexPair] = []
for pattern, label in zip(patterns, labels): for pattern, label in zip(patterns, labels):
rp = RegexPair(pattern, label) rp = RegexPair(pattern, label, count_only)
result.append(rp) result.append(rp)
self.regex_schemas = self.regex_schemas + rp.get_pyarrow_fields() self.regex_schemas = self.regex_schemas + rp.get_pyarrow_fields()
return result return result
@@ -795,7 +769,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,
@@ -883,14 +856,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
@@ -1220,6 +1191,14 @@ def main():
help="The label for the outputted column based on matching the regex in revision text.", help="The label for the outputted column based on matching the regex in revision text.",
) )
parser.add_argument(
"-RPc",
"--revision-pattern-count",
dest="regex_revision_output_count",
action="store_true",
help="If present, this will cause the revision patterns to return counts of the number of matches instead of the text of the matches themselves. It will affect all revision patterns.",
)
parser.add_argument( parser.add_argument(
"-CP", "-CP",
"--comment-pattern", "--comment-pattern",
@@ -1240,6 +1219,14 @@ def main():
help="The label for the outputted column based on matching the regex in comments.", help="The label for the outputted column based on matching the regex in comments.",
) )
parser.add_argument(
"-CPc",
"--comment-pattern-count",
dest="regex_comment_output_count",
action="store_true",
help="If present, this will cause the comment patterns to return counts of the number of matches instead of the text of the matches themselves. It will affect all comment patterns.",
)
parser.add_argument( parser.add_argument(
"-d", "-d",
"--diff", "--diff",
@@ -1352,15 +1339,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:
@@ -1378,8 +1356,8 @@ def main():
# Handle --print-schema: build and output schema, then exit # Handle --print-schema: build and output schema, then exit
if args.print_schema: if args.print_schema:
regex_revision_pairs = make_regex_pairs(args.regex_match_revision, args.regex_revision_label) regex_revision_pairs = make_regex_pairs(args.regex_match_revision, args.regex_revision_label, args.regex_revision_output_count)
regex_comment_pairs = make_regex_pairs(args.regex_match_comment, args.regex_comment_label) regex_comment_pairs = make_regex_pairs(args.regex_match_comment, args.regex_comment_label, args.regex_comment_output_count)
table, _, _ = build_table( table, _, _ = build_table(
text=args.text, text=args.text,
@@ -1457,8 +1435,10 @@ def main():
revert_radius=args.revert_radius, revert_radius=args.revert_radius,
regex_match_revision=args.regex_match_revision, regex_match_revision=args.regex_match_revision,
regex_revision_label=args.regex_revision_label, regex_revision_label=args.regex_revision_label,
regex_revision_output_count=args.regex_revision_output_count,
regex_match_comment=args.regex_match_comment, regex_match_comment=args.regex_match_comment,
regex_comment_label=args.regex_comment_label, regex_comment_label=args.regex_comment_label,
regex_comment_output_count=args.regex_comment_output_count,
text=args.text, text=args.text,
diff=args.diff, diff=args.diff,
output_jsonl=output_jsonl, output_jsonl=output_jsonl,
@@ -1514,8 +1494,10 @@ def main():
revert_radius=args.revert_radius, revert_radius=args.revert_radius,
regex_match_revision=args.regex_match_revision, regex_match_revision=args.regex_match_revision,
regex_revision_label=args.regex_revision_label, regex_revision_label=args.regex_revision_label,
regex_revision_output_count=args.regex_revision_output_count,
regex_match_comment=args.regex_match_comment, regex_match_comment=args.regex_match_comment,
regex_comment_label=args.regex_comment_label, regex_comment_label=args.regex_comment_label,
regex_comment_output_count=args.regex_comment_output_count,
diff=args.diff, diff=args.diff,
text=args.text, text=args.text,
batch_size=args.batch_size, batch_size=args.batch_size,

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

@@ -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')
@@ -427,6 +420,55 @@ def test_regex_deleted_revisions():
assert deleted["npov_npov"].isna().all() assert deleted["npov_npov"].isna().all()
assert deleted["talk_talk"].isna().all() assert deleted["talk_talk"].isna().all()
def test_regex_count_only():
# count mode returns match counts instead of the matched text, with 0
# (never None) for revisions with no matches or deleted content
pair = RegexPair(r"\bcat\b", "cats", count_only=True)
assert pair.matchmake("cat cat dog cat") == {"cats": 3}
assert pair.matchmake("dog") == {"cats": 0}
assert pair.matchmake(None) == {"cats": 0}
assert pair.get_pyarrow_fields()[0].type == pa.int64()
pair = RegexPair(r"(?P<a>\bcat\b)|(?P<b>\bdog\b)", "pets", count_only=True)
assert pair.matchmake("cat dog cat") == {"pets_a": 2, "pets_b": 1}
assert pair.matchmake("bird") == {"pets_a": 0, "pets_b": 0}
assert pair.matchmake(None) == {"pets_a": 0, "pets_b": 0}
assert all(f.type == pa.int64() for f in pair.get_pyarrow_fields())
# without count_only the original behavior is unchanged
pair = RegexPair(r"\bcat\b", "cats")
assert pair.matchmake("cat cat dog cat") == {"cats": "cat, cat, cat"}
assert pair.get_pyarrow_fields()[0].type == pa.string()
def test_regex_count_e2e():
# -RPc/-CPc produce integer columns whose values equal the number of
# matches in the revision text and comment respectively
tester = WikiqTester(wiki=REGEXTEST, case_name="regex_count")
try:
tester.call_wikiq(
"--text",
"-RP 'TestCase' -RPl testcases -RPc",
"-CP '\\b[Cc]hevalier\\b' -CPl chev -CPc",
)
except subprocess.CalledProcessError as exc:
pytest.fail(exc.stderr.decode("utf8"))
import re
test = pd.read_table(tester.output)
assert test["testcases"].dtype == "int64"
assert test["chev"].dtype == "int64"
for _, row in test.iterrows():
text = row["text"] if isinstance(row["text"], str) else None
expected = len(re.findall("TestCase", text)) if text is not None else 0
assert row["testcases"] == expected, f"revid {row['revid']}: {row['testcases']} != {expected}"
comment = row["edit_summary"] if isinstance(row["edit_summary"], str) else None
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}"
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