feat(python): add native distribution and release infrastructure
- add the Rust-backed Python API with PyStemmer compatibility - distribute standard compiled models as a separate Python package - generate model artifacts during builds instead of storing them in Git - add GitHub release and Pages-backed package index workflows - add Python tests, benchmarks, documentation, and Gradle integration - refresh the documentation site, branding, and language benchmarks
This commit is contained in:
44
python/tests/conftest.py
Normal file
44
python/tests/conftest.py
Normal file
@@ -0,0 +1,44 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2026, Leo Galambos
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
###############################################################################
|
||||
|
||||
"""Make the generated standard resource package available to source tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY = Path(__file__).resolve().parents[2]
|
||||
GENERATED_MODELS_DISTRIBUTION = (
|
||||
REPOSITORY / "build" / "python" / "generated" / "models-standard"
|
||||
)
|
||||
if GENERATED_MODELS_DISTRIBUTION.is_dir():
|
||||
sys.path.insert(0, str(GENERATED_MODELS_DISTRIBUTION))
|
||||
93
python/tests/test_distribution_verifier.py
Normal file
93
python/tests/test_distribution_verifier.py
Normal file
@@ -0,0 +1,93 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2026, Leo Galambos
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
###############################################################################
|
||||
|
||||
"""Regression tests for Python release-archive metadata validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_standard_distribution import ( # noqa: E402
|
||||
_ignore_build_artifacts,
|
||||
_validate_generated_project,
|
||||
)
|
||||
from verify_distributions import _assert_main_dependency # noqa: E402
|
||||
|
||||
|
||||
def test_standard_model_source_tree_contains_no_generated_payload() -> None:
|
||||
source = Path(__file__).resolve().parents[1] / "models-standard"
|
||||
package = source / "radixor_models_standard"
|
||||
|
||||
assert not (package / "manifest.json").exists()
|
||||
assert not list((package / "models").glob("*.rxc"))
|
||||
assert not list((package / "notices").glob("*/NOTICE-model-data.txt"))
|
||||
|
||||
|
||||
def test_standard_model_build_rejects_source_skeleton() -> None:
|
||||
source = Path(__file__).resolve().parents[1] / "models-standard"
|
||||
|
||||
with pytest.raises(ValueError, match="manifest is missing"):
|
||||
_validate_generated_project(source)
|
||||
|
||||
|
||||
def test_standard_model_build_ignores_local_build_state() -> None:
|
||||
names = [
|
||||
"build",
|
||||
"dist",
|
||||
"radixor_models_standard.egg-info",
|
||||
"__pycache__",
|
||||
"module.pyc",
|
||||
"module.pyo",
|
||||
"manifest.json",
|
||||
]
|
||||
|
||||
assert _ignore_build_artifacts("unused", names) == set(names[:-1])
|
||||
|
||||
|
||||
def test_native_distribution_requires_compatible_standard_models() -> None:
|
||||
metadata = Message()
|
||||
metadata["Requires-Dist"] = "radixor-models-standard >=1.0, <2.0"
|
||||
|
||||
_assert_main_dependency(metadata, "radixor-4.1.0.tar.gz")
|
||||
|
||||
|
||||
def test_native_distribution_rejects_missing_standard_models() -> None:
|
||||
metadata = Message()
|
||||
|
||||
with pytest.raises(ValueError, match="standard-model dependency"):
|
||||
_assert_main_dependency(metadata, "radixor-4.1.0.tar.gz")
|
||||
303
python/tests/test_radixor.py
Normal file
303
python/tests/test_radixor.py
Normal file
@@ -0,0 +1,303 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2026, Leo Galambos
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
###############################################################################
|
||||
|
||||
"""Acceptance tests for the radixor Python extension.
|
||||
|
||||
Run after building the extension:
|
||||
|
||||
cd python/
|
||||
pip install maturin pytest
|
||||
maturin develop --release
|
||||
pytest -q
|
||||
|
||||
The synthetic tests are self-contained and deterministic (no network, no
|
||||
bundled data).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from radixor import Stemmer
|
||||
|
||||
|
||||
def _write_gz_dict(lines: list[str], tmp_path: Path) -> str:
|
||||
"""Write a gzipped TSV dictionary into pytest's temporary directory."""
|
||||
path = tmp_path / "dictionary.gz"
|
||||
with gzip.open(path, "wt", encoding="utf-8", newline="\n") as gz:
|
||||
gz.write("\n".join(lines))
|
||||
return str(path)
|
||||
|
||||
|
||||
# Synthetic, deterministic pipeline tests.
|
||||
|
||||
|
||||
def test_backward_suffix_stemming_roundtrip(tmp_path: Path):
|
||||
# stem<TAB>variant... ; backward (suffix) stemming.
|
||||
dict_lines = [
|
||||
"run\trunning\truns\tran",
|
||||
"cat\tcats",
|
||||
"walk\twalking\twalks\twalked",
|
||||
]
|
||||
path = _write_gz_dict(dict_lines, tmp_path)
|
||||
s = Stemmer(path=path, backward=True, store_original=True)
|
||||
|
||||
# Every listed variant must stem back to its canonical stem.
|
||||
assert s.stem("running") == "run"
|
||||
assert s.stem("runs") == "run"
|
||||
assert s.stem("ran") == "run"
|
||||
assert s.stem("cats") == "cat"
|
||||
assert s.stem("walking") == "walk"
|
||||
assert s.stem("walked") == "walk"
|
||||
|
||||
# store_original: the stem itself is recognised (no-op patch).
|
||||
assert s.stem("run") == "run"
|
||||
assert s.stem("cat") == "cat"
|
||||
|
||||
|
||||
def test_store_original_controls_bare_stem_identity(tmp_path: Path):
|
||||
# With a single rule and store_original=True, the stem maps to itself via
|
||||
# the no-op patch, and the "cat" vs "cats" terminals carry different values
|
||||
# so the trie does NOT collapse to a universal rule.
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s_keep = Stemmer(path=path, backward=True, store_original=True)
|
||||
assert s_keep.stem("cats") == "cat"
|
||||
assert s_keep.stem("cat") == "cat"
|
||||
|
||||
# With store_original=False, only the single rule cats->cat is present.
|
||||
# Radixor's always-on uniform-subtree contraction generalizes that lone
|
||||
# rule to ALL input (this is the intended generalization behavior), so the
|
||||
# bare stem is rewritten by the same delete-one-suffix rule.
|
||||
s_drop = Stemmer(path=path, backward=True, store_original=False)
|
||||
assert s_drop.stem("cats") == "cat"
|
||||
assert s_drop.stem("cat") == "ca" # generalized: delete final char
|
||||
assert s_drop.stem("dogs") == "dog" # rule applies to unseen input too
|
||||
|
||||
|
||||
def test_unknown_word_returns_none(tmp_path: Path):
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True)
|
||||
assert s.stem("zzzunknown") is None
|
||||
|
||||
|
||||
def test_pystemmer_scalar_api_returns_original_word_for_unknown(tmp_path: Path):
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True)
|
||||
|
||||
assert s.stemWord("cats") == "cat"
|
||||
assert s.stemWord("ZzZUnknown") == "ZzZUnknown"
|
||||
# The original Radixor API keeps its existing missing-value contract.
|
||||
assert s.stem("ZzZUnknown") is None
|
||||
|
||||
|
||||
def test_pystemmer_batch_api_returns_original_words_for_unknowns(tmp_path: Path):
|
||||
path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True)
|
||||
words = ["running", "Nope", "cats", "QzXqZx"]
|
||||
|
||||
assert s.stemWords(words) == ["run", "Nope", "cat", "QzXqZx"]
|
||||
assert s.stem_batch(words) == ["run", None, "cat", None]
|
||||
|
||||
|
||||
def test_pystemmer_batch_cache_does_not_change_original_api(tmp_path: Path):
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True, cache_size=100)
|
||||
|
||||
assert s.stemWords(["Unknown", "cats", "Unknown"]) == ["Unknown", "cat", "Unknown"]
|
||||
assert s.stem_batch(["Unknown", "cats", "Unknown"]) == [None, "cat", None]
|
||||
|
||||
|
||||
def test_wrapper_forwards_default_cache_size_and_zero_opt_out(monkeypatch):
|
||||
import radixor
|
||||
|
||||
constructor_calls = []
|
||||
|
||||
class RecordingStemmerCore:
|
||||
def __init__(self, *args):
|
||||
constructor_calls.append(args)
|
||||
|
||||
monkeypatch.setattr(radixor, "StemmerCore", RecordingStemmerCore)
|
||||
|
||||
radixor.Stemmer(path="model.rxc")
|
||||
radixor.Stemmer(path="model.rxc", cache_size=0)
|
||||
|
||||
assert constructor_calls[0][-1] == 10_000
|
||||
assert constructor_calls[1][-1] == 0
|
||||
|
||||
|
||||
def test_native_constructor_default_cache_size():
|
||||
from radixor._radixor import StemmerCore
|
||||
|
||||
assert inspect.signature(StemmerCore).parameters["cache_size"].default == 10_000
|
||||
|
||||
|
||||
def test_pystemmer_language_name_alias():
|
||||
import radixor as StemmerModule
|
||||
|
||||
# Only the dependency/import line changes from PyStemmer's conventional
|
||||
# ``import Stemmer; Stemmer.Stemmer("english")`` usage.
|
||||
s = StemmerModule.Stemmer("english")
|
||||
assert s.stemWord("running") == "run"
|
||||
assert s.stemWords(["running", "unknown_word"]) == ["run", "unknown_word"]
|
||||
|
||||
|
||||
def test_case_is_lowercased(tmp_path: Path):
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True)
|
||||
assert s.stem("CATS") == "cat"
|
||||
assert s.stem("Cats") == "cat"
|
||||
|
||||
|
||||
def test_batch_matches_scalar(tmp_path: Path):
|
||||
path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True)
|
||||
words = ["running", "runs", "cats", "nope", "run"]
|
||||
assert s.stem_batch(words) == [s.stem(w) for w in words]
|
||||
|
||||
|
||||
def test_compile_roundtrip_matches_from_text(tmp_path: Path):
|
||||
import os
|
||||
|
||||
import radixor
|
||||
|
||||
dict_lines = [
|
||||
"run\trunning\truns\tran",
|
||||
"cat\tcats",
|
||||
"walk\twalking\twalks\twalked",
|
||||
]
|
||||
src = _write_gz_dict(dict_lines, tmp_path)
|
||||
out = src + ".rxc"
|
||||
radixor.compile(src, out, backward=True)
|
||||
|
||||
from_text = Stemmer(path=src, backward=True)
|
||||
from_compiled = Stemmer(compiled=out)
|
||||
|
||||
words = [
|
||||
"running",
|
||||
"runs",
|
||||
"ran",
|
||||
"cats",
|
||||
"walking",
|
||||
"walked",
|
||||
"run",
|
||||
"cat",
|
||||
"walk",
|
||||
"unknownzzz",
|
||||
]
|
||||
assert from_compiled.stem_batch(words) == from_text.stem_batch(words)
|
||||
# The compiled artifact uses the gzip-wrapped EGTR v7 stream format.
|
||||
import gzip
|
||||
|
||||
with gzip.open(out, "rb") as fh:
|
||||
assert fh.read(4) == b"EGTR"
|
||||
os.unlink(out)
|
||||
|
||||
|
||||
def test_cache_does_not_change_results(tmp_path: Path):
|
||||
path = _write_gz_dict(["run\trunning\truns", "cat\tcats"], tmp_path)
|
||||
plain = Stemmer(path=path, backward=True, cache_size=0)
|
||||
cached = Stemmer(path=path, backward=True, cache_size=1000)
|
||||
words = ["running", "runs", "cats", "nope", "run", "running", "cats"]
|
||||
assert cached.stem_batch(words) == plain.stem_batch(words)
|
||||
# Repeated lookups exercise the cache-hit path.
|
||||
assert cached.stem_batch(["running"] * 5) == ["run"] * 5
|
||||
|
||||
|
||||
def test_default_cache_is_shared_across_scalar_and_batch_apis(tmp_path: Path):
|
||||
root = "cacheable-root-value"
|
||||
variant = "cacheable-root-values"
|
||||
path = _write_gz_dict([f"{root}\t{variant}"], tmp_path)
|
||||
cached = Stemmer(path=path, backward=True)
|
||||
|
||||
first = cached.stem(variant)
|
||||
assert first == root
|
||||
assert cached.stem(variant) is first
|
||||
assert cached.stemWord(variant) is first
|
||||
assert cached.stem_batch([variant])[0] is first
|
||||
assert cached.stemWords([variant])[0] is first
|
||||
|
||||
disabled = Stemmer(path=path, backward=True, cache_size=0)
|
||||
uncached_first = disabled.stem(variant)
|
||||
uncached_second = disabled.stem(variant)
|
||||
assert uncached_first == uncached_second == root
|
||||
assert uncached_first is not uncached_second
|
||||
|
||||
|
||||
def test_full_cache_keeps_existing_entries_without_admitting_new_ones(tmp_path: Path):
|
||||
roots = ("first-cacheable-root", "second-cacheable-root")
|
||||
variants = tuple(f"{root}-value" for root in roots)
|
||||
path = _write_gz_dict(
|
||||
[f"{root}\t{variant}" for root, variant in zip(roots, variants)], tmp_path
|
||||
)
|
||||
stemmer = Stemmer(path=path, backward=True, cache_size=1)
|
||||
|
||||
first = stemmer.stem(variants[0])
|
||||
assert stemmer.stem(variants[0]) is first
|
||||
|
||||
uncached = stemmer.stem(variants[1])
|
||||
assert uncached == roots[1]
|
||||
assert stemmer.stem(variants[1]) == uncached
|
||||
assert stemmer.stem(variants[1]) is not uncached
|
||||
assert stemmer.stem(variants[0]) is first
|
||||
|
||||
|
||||
def test_lowercase_false_assumes_prelowered(tmp_path: Path):
|
||||
path = _write_gz_dict(["cat\tcats"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True, lowercase=False)
|
||||
assert s.stem("cats") == "cat" # already-lowercase input works
|
||||
assert s.stem("CATS") is None # not lowercased -> no match
|
||||
|
||||
|
||||
def test_forward_prefix_stemming(tmp_path: Path):
|
||||
# Forward traversal handles prefix-oriented morphology (RTL languages).
|
||||
path = _write_gz_dict(["kitab\talkitab\talkitabu"], tmp_path)
|
||||
s = Stemmer(path=path, backward=False, store_original=True)
|
||||
assert s.stem("alkitab") == "kitab"
|
||||
assert s.stem("alkitabu") == "kitab"
|
||||
assert s.stem("kitab") == "kitab"
|
||||
|
||||
|
||||
def test_stem_all_returns_candidates(tmp_path: Path):
|
||||
path = _write_gz_dict(["run\trunning", "runn\trunning"], tmp_path)
|
||||
s = Stemmer(path=path, backward=True, store_original=True)
|
||||
alls = s.stem_all("running")
|
||||
# "running" maps to both "run" and "runn"; both must be reachable.
|
||||
assert set(alls) >= {"run", "runn"}
|
||||
|
||||
|
||||
# Installed standard-model smoke test.
|
||||
|
||||
|
||||
def test_installed_english_compiled_model():
|
||||
s = Stemmer("en")
|
||||
assert s.stem_batch(["running", "walked", "cats"]) == ["run", "walk", "cat"]
|
||||
179
python/tests/test_standard_models.py
Normal file
179
python/tests/test_standard_models.py
Normal file
@@ -0,0 +1,179 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2026, Leo Galambos
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software
|
||||
# without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
###############################################################################
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import nullcontext
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import radixor
|
||||
|
||||
EXPECTED_MODEL_IDS = {
|
||||
"cs-cz-default",
|
||||
"da-dk-default",
|
||||
"de-de-default",
|
||||
"es-es-default",
|
||||
"fa-ir-default",
|
||||
"fi-fi-default",
|
||||
"fr-fr-default",
|
||||
"he-il-default",
|
||||
"hu-hu-default",
|
||||
"it-it-default",
|
||||
"nb-no-default",
|
||||
"nl-nl-default",
|
||||
"nn-no-default",
|
||||
"pl-pl-unimorph",
|
||||
"pt-pt-default",
|
||||
"ru-ru-default",
|
||||
"sv-se-default",
|
||||
"uk-ua-default",
|
||||
"us-uk-default",
|
||||
"yi-default",
|
||||
}
|
||||
|
||||
|
||||
def _manifest() -> dict:
|
||||
ref = resources.files("radixor_models_standard").joinpath("manifest.json")
|
||||
return json.loads(ref.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_standard_manifest_model_set_versions_and_license():
|
||||
manifest = _manifest()
|
||||
assert manifest["catalog_version"] == "2026.1"
|
||||
assert manifest["distribution_version"] == "0.0.0"
|
||||
assert manifest["format"] == {"compression": "gzip", "magic": "EGTR", "version": 7}
|
||||
assert {model["id"] for model in manifest["models"]} == EXPECTED_MODEL_IDS
|
||||
assert "pl-pl-polimorf" not in {model["id"] for model in manifest["models"]}
|
||||
assert {model["version"] for model in manifest["models"]} == {"1.0.0"}
|
||||
assert {model["provenance"]["license"] for model in manifest["models"]} == {
|
||||
"CC-BY-SA-3.0"
|
||||
}
|
||||
|
||||
|
||||
def test_standard_artifact_checksums_and_v7_headers():
|
||||
root = resources.files("radixor_models_standard")
|
||||
for model in _manifest()["models"]:
|
||||
data = root.joinpath("models").joinpath(f"{model['id']}.rxc").read_bytes()
|
||||
assert hashlib.sha256(data).hexdigest() == model["sha256"]
|
||||
assert gzip.decompress(data)[:8] == b"EGTR\x00\x00\x00\x07"
|
||||
|
||||
|
||||
def test_missing_standard_data_package_is_actionable(monkeypatch):
|
||||
original_files = radixor.importlib.resources.files
|
||||
|
||||
def missing(package: str):
|
||||
if package == radixor._STANDARD_PACKAGE:
|
||||
raise ModuleNotFoundError(package)
|
||||
return original_files(package)
|
||||
|
||||
monkeypatch.setattr(radixor.importlib.resources, "files", missing)
|
||||
with pytest.raises(
|
||||
ModuleNotFoundError, match="pip install radixor-models-standard"
|
||||
):
|
||||
radixor.Stemmer("en")
|
||||
|
||||
|
||||
def test_missing_standard_model_is_actionable():
|
||||
with pytest.raises(FileNotFoundError, match="not in the standard Radixor catalog"):
|
||||
radixor.Stemmer("zz-zz-default")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
(
|
||||
lambda manifest: manifest.update(catalog_version="2027.1"),
|
||||
"incompatible or corrupt",
|
||||
),
|
||||
(
|
||||
lambda manifest: manifest.update(format={"magic": "bad"}),
|
||||
"incompatible or corrupt",
|
||||
),
|
||||
(
|
||||
lambda manifest: manifest.update(distribution_version="2.0.0"),
|
||||
"incompatible or corrupt",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_incompatible_manifest_is_actionable(
|
||||
tmp_path: Path, monkeypatch, mutation, message
|
||||
):
|
||||
manifest = _manifest()
|
||||
mutation(manifest)
|
||||
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
monkeypatch.setattr(radixor.importlib.resources, "files", lambda package: tmp_path)
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
radixor.Stemmer("en")
|
||||
|
||||
|
||||
def test_checksum_failure_is_detected(tmp_path: Path, monkeypatch):
|
||||
manifest = _manifest()
|
||||
(tmp_path / "models").mkdir()
|
||||
(tmp_path / "models" / "us-uk-default.rxc").write_bytes(b"not the model")
|
||||
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
monkeypatch.setattr(radixor.importlib.resources, "files", lambda package: tmp_path)
|
||||
monkeypatch.setattr(radixor.importlib.resources, "as_file", nullcontext)
|
||||
with pytest.raises(RuntimeError, match="SHA-256 validation"):
|
||||
radixor.Stemmer("en")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stream_header", "message"),
|
||||
[
|
||||
(b"NOPE\x00\x00\x00\x07", "EGTR format marker"),
|
||||
(b"EGTR\x00\x00\x00\x08", "unsupported compiled format v8"),
|
||||
],
|
||||
)
|
||||
def test_format_marker_and_version_are_validated(
|
||||
tmp_path: Path, monkeypatch, stream_header: bytes, message: str
|
||||
):
|
||||
manifest = _manifest()
|
||||
model = next(item for item in manifest["models"] if item["id"] == "us-uk-default")
|
||||
data = gzip.compress(stream_header, mtime=0)
|
||||
model["sha256"] = hashlib.sha256(data).hexdigest()
|
||||
(tmp_path / "models").mkdir()
|
||||
(tmp_path / "models" / "us-uk-default.rxc").write_bytes(data)
|
||||
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
monkeypatch.setattr(radixor.importlib.resources, "files", lambda package: tmp_path)
|
||||
monkeypatch.setattr(radixor.importlib.resources, "as_file", nullcontext)
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
radixor.Stemmer("en")
|
||||
|
||||
|
||||
def test_invalid_model_id_is_rejected_before_resource_lookup():
|
||||
with pytest.raises(ValueError, match="Invalid Radixor model ID"):
|
||||
radixor.Stemmer("../us-uk-default")
|
||||
Reference in New Issue
Block a user