The fork takes its own name everywhere a user can see it: binaries (voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus namespace (com.voculinux.Voculinux, no alias for the old name), config/data/cache dirs, single-instance lock, IBus engine identity, tray icons, resource prefixes, VOCULINUX_* environment names, and the update-checker user agent. The internal Python module stays vocalinux and the distribution metadata keeps the upstream URLs as provenance. On first run a previous Vocalinux install's config.json, custom dictionary, and portal restore token are copied once into the new locations (new config always wins, no merge, no write-back); models re-resolve through the pinned-checksum download path. An offline naming-sweep test proves no old-name strings remain on user-visible surfaces outside the allowlist. Refs #18
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""Guard against Python-version drift between the places that declare it.
|
|
|
|
`requires-python`, the classifiers, the CI matrix and install.sh's MIN_VERSION
|
|
used to disagree (3.9 floor, 3.9-3.13 matrix, classifiers promising 3.14), which
|
|
is how an unsupported interpreter can quietly become the one users install with.
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
|
PIPELINE = REPO_ROOT / ".github" / "workflows" / "unified-pipeline.yml"
|
|
PKGBUILD = REPO_ROOT / "packaging" / "aur" / "voculinux" / "PKGBUILD"
|
|
|
|
# Everything user-facing that could state the floor. Scanned as trees rather than
|
|
# a fixed list: docs and the website drifted to "3.9+" for a full release cycle
|
|
# after the floor moved, and a new page must not be able to reintroduce it.
|
|
DOC_AND_SITE_GLOBS = [
|
|
(REPO_ROOT / "docs", "**/*.md"),
|
|
(REPO_ROOT / "web" / "src", "**/*.tsx"),
|
|
(REPO_ROOT / "web" / "src", "**/*.ts"),
|
|
(REPO_ROOT, "README.md"),
|
|
(REPO_ROOT, "CONTRIBUTING.md"),
|
|
]
|
|
|
|
|
|
def _doc_and_site_files() -> list:
|
|
files = []
|
|
for base, pattern in DOC_AND_SITE_GLOBS:
|
|
files.extend(p for p in base.glob(pattern) if "node_modules" not in p.parts)
|
|
return sorted(set(files))
|
|
|
|
|
|
def _version_tuple(version: str) -> tuple:
|
|
return tuple(int(part) for part in version.split("."))
|
|
|
|
|
|
def _pyproject() -> dict:
|
|
with PYPROJECT.open("rb") as handle:
|
|
return tomllib.load(handle)
|
|
|
|
|
|
def _requires_python_floor() -> str:
|
|
requires = _pyproject()["project"]["requires-python"]
|
|
match = re.fullmatch(r">=\s*(\d+\.\d+)", requires.strip())
|
|
assert match, f"unexpected requires-python format: {requires!r}"
|
|
return match.group(1)
|
|
|
|
|
|
def _classifier_versions() -> list:
|
|
versions = []
|
|
for classifier in _pyproject()["project"]["classifiers"]:
|
|
match = re.fullmatch(r"Programming Language :: Python :: (\d+\.\d+)", classifier)
|
|
if match:
|
|
versions.append(match.group(1))
|
|
return versions
|
|
|
|
|
|
def _ci_matrix_versions() -> list:
|
|
match = re.search(r"python-version: \[([^\]]+)\]", PIPELINE.read_text(encoding="utf-8"))
|
|
assert match, "python-version matrix not found in unified-pipeline.yml"
|
|
return re.findall(r"'(\d+\.\d+)'", match.group(1))
|
|
|
|
|
|
def _install_sh_min_version() -> str:
|
|
# Anchored on the function: install.sh also carries an Ubuntu MIN_VERSION.
|
|
match = re.search(
|
|
r'check_python_version\(\) \{.*?local MIN_VERSION="(\d+\.\d+)"',
|
|
INSTALL_SH.read_text(encoding="utf-8"),
|
|
re.DOTALL,
|
|
)
|
|
assert match, "check_python_version's MIN_VERSION not found in install.sh"
|
|
return match.group(1)
|
|
|
|
|
|
def test_installer_floor_matches_requires_python():
|
|
assert _install_sh_min_version() == _requires_python_floor()
|
|
|
|
|
|
def test_classifiers_match_supported_range():
|
|
floor = _requires_python_floor()
|
|
classifiers = _classifier_versions()
|
|
|
|
assert classifiers, "no Programming Language :: Python :: X.Y classifiers"
|
|
assert min(map(_version_tuple, classifiers)) == _version_tuple(floor)
|
|
|
|
|
|
def test_ci_matrix_covers_every_supported_version():
|
|
# The matrix is the only thing that proves a promised version works.
|
|
assert sorted(_ci_matrix_versions()) == sorted(_classifier_versions())
|
|
|
|
|
|
def test_running_interpreter_is_supported():
|
|
assert sys.version_info[:2] >= _version_tuple(_requires_python_floor())
|
|
|
|
|
|
def _pkgbuild_python_floor() -> str:
|
|
match = re.search(r"'python>=(\d+\.\d+)'", PKGBUILD.read_text(encoding="utf-8"))
|
|
assert match, "python>= dependency not found in the AUR PKGBUILD"
|
|
return match.group(1)
|
|
|
|
|
|
def test_aur_package_matches_requires_python():
|
|
assert _pkgbuild_python_floor() == _requires_python_floor()
|
|
|
|
|
|
def test_docs_and_site_do_not_advertise_an_older_floor():
|
|
"""No user-facing file may promise a Python older than the real floor."""
|
|
floor = _version_tuple(_requires_python_floor())
|
|
files = _doc_and_site_files()
|
|
assert files, "found nothing to scan; the docs/website layout moved"
|
|
|
|
offenders = []
|
|
for path in files:
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
for number, line in enumerate(text.splitlines(), 1):
|
|
for claimed in re.findall(r"Python (\d+\.\d+)\+", line):
|
|
if _version_tuple(claimed) < floor:
|
|
rel = path.relative_to(REPO_ROOT)
|
|
offenders.append(f"{rel}:{number} claims Python {claimed}+")
|
|
|
|
assert not offenders, "user-facing files promise an unsupported Python:\n" + "\n".join(
|
|
offenders
|
|
)
|