diff --git a/pyproject.toml b/pyproject.toml index 54a54da0409..73d0fba4965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,16 @@ dependencies = [ "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). "croniter==6.0.0", + # ``packaging`` is imported directly on three production paths but was never + # declared, so it only reached users transitively (pip/uv pull it for other + # tools). The slim official Docker image ships without it, where the + # try/except-ImportError fallbacks silently degrade: Hindsight's + # ``_meets_minimum_version`` disables update_mode='append' (#40503), + # tools/lazy_deps.py treats every version constraint as satisfied, and + # hermes_cli/main.py drops to naive requirement parsing. Pure-Python + # py3-none-any wheel, no compiled extensions — safe to ship everywhere. + # Pinned to the version already resolved in uv.lock (no resolution churn). + "packaging==26.0", # Markdown -> HTML conversion for rich message delivery (Matrix # `formatted_body`, and the `send_message` tool's HTML path). Now on the # DEFAULT delivery path, not matrix-specific: without it both diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index b4c367b142d..edc1cb6d1b3 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -1,4 +1,5 @@ from pathlib import Path +import re import tomllib import pytest @@ -14,6 +15,22 @@ find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_pac REPO_ROOT = Path(__file__).resolve().parents[1] +def _distribution_name(requirement: str) -> str: + """Extract the PEP 508 distribution name from a requirement string. + + Robust to markers (``; python_version < '3.12'``), direct references + (``name @ https://...``), extras (``name[extra]``) and every version + operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a + future dep declared with any valid specifier shape doesn't silently + mis-parse here. + """ + spec = requirement.split(";", 1)[0] # drop environment markers + spec = spec.split("@", 1)[0] # drop direct-reference URLs + spec = spec.split("[", 1)[0] # drop extras + spec = re.split(r"[=<>!~]", spec, maxsplit=1)[0] # drop any version operator + return spec.strip().lower() + + def _packages_find_include(): data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) return data["tool"]["setuptools"]["packages"]["find"]["include"] @@ -61,6 +78,27 @@ def test_every_on_disk_subpackage_is_covered_by_packages_find(): ) +def test_packaging_declared_as_core_dependency(): + """Regression for #40503. + + ``packaging`` is imported directly on three production paths + (plugins/memory/hindsight/__init__.py, tools/lazy_deps.py, + hermes_cli/main.py) yet was undeclared, so it only reached users + transitively. The slim Docker image shipped without it, silently + disabling Hindsight append-mode and version-constraint checks. It must + be a declared core dependency so it installs everywhere and the + update-repair step (``_verify_core_dependencies_installed``) guards it. + """ + data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + core = data["project"]["dependencies"] + names = {_distribution_name(dep) for dep in core} + assert "packaging" in names, ( + "packaging is imported on production paths (hindsight version compare, " + "lazy_deps version constraints, requirement parsing) and must be a " + "declared core dependency, not a transitive — see #40503" + ) + + def test_faster_whisper_is_not_a_base_dependency(): data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) deps = data["project"]["dependencies"] diff --git a/uv.lock b/uv.lock index e7d487bf636..bb8718d9b50 100644 --- a/uv.lock +++ b/uv.lock @@ -1400,6 +1400,7 @@ dependencies = [ { name = "jinja2" }, { name = "markdown" }, { name = "openai" }, + { name = "packaging" }, { name = "pathspec" }, { name = "pillow" }, { name = "prompt-toolkit" }, @@ -1650,6 +1651,7 @@ requires-dist = [ { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, + { name = "packaging", specifier = "==26.0" }, { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, { name = "pathspec", specifier = "==1.1.1" }, { name = "pillow", specifier = "==12.2.0" },