Compare commits
30 Commits
regex-coun
...
archive/co
| Author | SHA1 | Date | |
|---|---|---|---|
| c49fa5af3c | |||
| dfd2dbe20f | |||
| 851f070c8f | |||
| fde1452666 | |||
| c6117a7182 | |||
| 0b1557efc5 | |||
| aedd4b3086 | |||
| 2bee25488b | |||
| 702101bfe2 | |||
| 1700561b07 | |||
| 40132f04dd | |||
| 3d6bf34c8e | |||
| c1ee926211 | |||
| d6d0aab604 | |||
| 90a14cf999 | |||
| 5fb7b5596c | |||
| fa8adc30ca | |||
| 45f0680200 | |||
| 0ed0f57dbb | |||
| 14886106ed | |||
| ec09641545 | |||
| 3ab988d6eb | |||
| 392211af17 | |||
| 0b6e0fbc70 | |||
| 6634de51e6 | |||
| 83b7fc0210 | |||
| 791be0aa56 | |||
| 7c27942cb0 | |||
| 72a8851373 | |||
| dbcae5c64e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "mediawiki-php-wikidiff2"]
|
||||||
|
path = mediawiki-php-wikidiff2
|
||||||
|
url = https://github.com/wikimedia/mediawiki-php-wikidiff2/
|
||||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -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.
|
|
||||||
69
README.md
69
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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/ "$@"
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user