152 lines
5.1 KiB
Python
152 lines
5.1 KiB
Python
"""Invariants for pyproject.toml after the channel install contract cleanup.
|
|
|
|
Real-used channel SDKs stay in base dependencies. Base-install channels
|
|
must not be re-exposed as empty optional extras, because package metadata
|
|
is also a user-visible install contract.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
UV_LOCK = Path(__file__).resolve().parents[2] / "uv.lock"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def project_table() -> dict:
|
|
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
|
return data["project"]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def lock_package() -> dict:
|
|
data = tomllib.loads(UV_LOCK.read_text(encoding="utf-8"))
|
|
return next(package for package in data["package"] if package["name"] == "opensquilla")
|
|
|
|
|
|
def _dep_names(specs: list[str]) -> set[str]:
|
|
"""Extract canonical (lowercased) package names from a list of PEP 508 specs."""
|
|
|
|
names: set[str] = set()
|
|
for spec in specs:
|
|
head = spec.strip()
|
|
for sep in ("[", " ", ";", "=", ">", "<", "~", "!"):
|
|
head = head.split(sep, 1)[0]
|
|
if head:
|
|
names.add(head.lower())
|
|
return names
|
|
|
|
|
|
def test_channel_sdks_in_base(project_table: dict) -> None:
|
|
"""Channel adapters that ``import`` a vendor SDK must keep that SDK in base."""
|
|
|
|
base = _dep_names(project_table["dependencies"])
|
|
required = {
|
|
"lark-oapi",
|
|
"python-telegram-bot",
|
|
"dingtalk-stream",
|
|
"qq-botpy",
|
|
"cryptography",
|
|
}
|
|
missing = required - base
|
|
assert not missing, f"channel SDKs missing from base deps: {sorted(missing)}"
|
|
|
|
|
|
def test_no_dead_extras(project_table: dict) -> None:
|
|
"""msteams extra is intentionally absent; matrix extra installs matrix-nio."""
|
|
|
|
extras = project_table.get("optional-dependencies", {})
|
|
assert "msteams" not in extras, (
|
|
"msteams extra stays absent: the adapter is text-only and not advertised"
|
|
)
|
|
assert "matrix" in extras, "matrix extra must exist and pull matrix-nio"
|
|
matrix_specs = extras["matrix"]
|
|
assert any("matrix-nio" in spec for spec in matrix_specs), (
|
|
"matrix extra must declare matrix-nio (without [e2e]); use matrix-e2e for E2EE"
|
|
)
|
|
|
|
|
|
def test_base_channel_extras_are_not_exposed_as_noop_aliases(
|
|
project_table: dict,
|
|
) -> None:
|
|
"""Base-install channels must not be exposed as no-op extras."""
|
|
|
|
extras = project_table.get("optional-dependencies", {})
|
|
for name in ("feishu", "telegram", "dingtalk", "wecom", "qq"):
|
|
assert name not in extras, f"{name} is installed from base; do not expose a no-op extra"
|
|
|
|
|
|
def test_lockfile_does_not_advertise_removed_base_channel_extras(
|
|
lock_package: dict,
|
|
) -> None:
|
|
"""uv.lock metadata must match the package install contract."""
|
|
|
|
provides_extras = set(lock_package.get("provides-extras", []))
|
|
for name in ("feishu", "telegram", "dingtalk", "wecom", "qq"):
|
|
assert name not in provides_extras
|
|
|
|
|
|
def test_no_duplicate_ml_extra(project_table: dict) -> None:
|
|
"""``recommended`` and ``model-router`` historically overlapped — only one survives."""
|
|
|
|
extras = project_table.get("optional-dependencies", {})
|
|
has_recommended = "recommended" in extras
|
|
has_model_router = "model-router" in extras
|
|
assert has_recommended, "recommended extra must exist (router users opt in here)"
|
|
assert not has_model_router, (
|
|
"model-router extra duplicates recommended — collapse into one"
|
|
)
|
|
|
|
|
|
def test_alpha_classifier_present(project_table: dict) -> None:
|
|
"""0.1.0 stays pre-stable — the classifier must reflect that."""
|
|
|
|
classifiers = project_table.get("classifiers", [])
|
|
assert "Development Status :: 3 - Alpha" in classifiers, (
|
|
"Alpha classifier signals to PyPI/uv that this is pre-stable"
|
|
)
|
|
|
|
|
|
def test_readme_points_at_user_facing_file(project_table: dict) -> None:
|
|
"""``readme`` must be the user-facing README, not the legacy portable view."""
|
|
|
|
assert project_table["readme"] == "README.md", (
|
|
"readme should point at the canonical README.md after the 0.1.0 refactor"
|
|
)
|
|
|
|
|
|
def test_html_coder_reference_files_are_packaged() -> None:
|
|
"""html-coder's SKILL.md links to local references that must survive wheels."""
|
|
|
|
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
|
|
force_include = data["tool"]["hatch"]["build"]["targets"]["wheel"][
|
|
"force-include"
|
|
]
|
|
references_dir = (
|
|
PYPROJECT.parent
|
|
/ "src"
|
|
/ "opensquilla"
|
|
/ "skills"
|
|
/ "bundled"
|
|
/ "html-coder"
|
|
/ "references"
|
|
)
|
|
expected_sources = {
|
|
path.relative_to(PYPROJECT.parent).as_posix()
|
|
for path in references_dir.glob("*.md")
|
|
}
|
|
|
|
missing_sources = expected_sources - set(force_include)
|
|
assert not missing_sources, (
|
|
"html-coder reference files must be force-included in wheels: "
|
|
f"{sorted(missing_sources)}"
|
|
)
|
|
for source in expected_sources:
|
|
assert (
|
|
force_include[source] == source.removeprefix("src/")
|
|
), f"{source} should be packaged at its import-time skill path"
|