Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 5 additions & 22 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -312,28 +312,11 @@ hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]

[tool.setuptools.data-files]
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
# neither a package (packages.find) nor package-data (which attaches to a
# package). data-files ships it in the wheel; MANIFEST.in `graft locales`
# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
# `gateway.reset.header_default` (#27632, #35374, #23943).
locales = ["locales/*.yaml"]
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
# dashboard catalog screen come up empty on packaged installs even though the
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
# the packaged dir; list_catalog() returns [] when it's missing).
#
# data-files flattens every glob match into its single target dir, so each
# catalog entry needs its OWN target to preserve the per-entry directory the
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
# manifests into one colliding optional-mcps/manifest.yaml). One target per
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
# data-files are declared in setup.py via _data_file_tree() to avoid
# a setuptools conflict where pyproject.toml [tool.setuptools.data-files]
# replaces setup.py data_files entirely instead of merging (#66733).
# All data directories (skills, optional-skills, locales, optional-mcps)
# are handled there.

[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,7 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
data_files=[
*_data_file_tree("skills"),
*_data_file_tree("optional-skills"),
*_data_file_tree("locales"),
*_data_file_tree("optional-mcps"),
]
)
58 changes: 50 additions & 8 deletions tests/test_packaging_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,19 +242,27 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():


def test_locale_catalogs_ship_in_both_wheel_and_sdist():
"""Regression test for #27632 / #35374 / #23943.
"""Regression test for #27632 / #35374 / #23943 / #66733.

locales/ is a bare data directory (no __init__.py), so it is invisible to
packages.find and to package-data (which attaches to a package). It must be
declared as setuptools data-files (wheel) AND grafted in MANIFEST.in
packages.find and to package-data (which attaches to a package). It must
be declared as setuptools data_files (wheel) AND grafted in MANIFEST.in
(sdist). Without both, sealed installs drop the catalogs and gateway/CLI
commands surface raw i18n keys like `gateway.reset.header_default`.

Historically this was enforced against pyproject.toml's
[tool.setuptools.data-files] section, but that section silently overrides
setup.py's data_files (setuptools does not merge them — see issue #66733),
which caused skills/optional-skills/ to fall out of the wheel. The
declaration has since moved to setup.py via _data_file_tree(); this test
now asserts the contract against setup.py.
"""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
data_files = data["tool"]["setuptools"].get("data-files", {})
assert data_files.get("locales") == ["locales/*.yaml"], (
"pyproject [tool.setuptools.data-files] must declare "
'locales = ["locales/*.yaml"] so the wheel ships i18n catalogs'
setup_py = (REPO_ROOT / "setup.py").read_text(encoding="utf-8")
assert '_data_file_tree("locales")' in setup_py, (
"setup.py must include _data_file_tree('locales') so the wheel ships "
"i18n catalogs. Declaring data-files in pyproject.toml "
"[tool.setuptools.data-files] instead would shadow setup.py "
"data_files and drop skills/optional-skills (#66733)."
)

manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
Expand All @@ -267,6 +275,40 @@ def test_locale_catalogs_ship_in_both_wheel_and_sdist():
assert on_disk, "expected locales/*.yaml catalogs on disk"


def test_skills_and_optional_skills_ship_in_wheel():
"""Regression test for #66733.

skills/ and optional-skills/ are bare data directories (no __init__.py),
so they are invisible to packages.find and to package-data. They must be
declared as setuptools data_files in setup.py so the wheel ships them.
Concurrently declaring [tool.setuptools.data-files] in pyproject.toml
would shadow setup.py data_files entirely (setuptools does not merge
them) and silently drop skills/optional-skills from the wheel — that is
the regression this test guards against.
"""
setup_py = (REPO_ROOT / "setup.py").read_text(encoding="utf-8")
# pyproject must NOT carry [tool.setuptools.data-files] — that section
# overrides setup.py data_files and would drop skills/optional-skills.
pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
setuptools_cfg = pyproject.get("tool", {}).get("setuptools", {})
assert "data-files" not in setuptools_cfg, (
"pyproject.toml [tool.setuptools.data-files] must not be declared — "
"it would shadow setup.py data_files and drop "
"skills/optional-skills/optional-mcps from the wheel (#66733)."
)

for root in ("skills", "optional-skills", "optional-mcps"):
assert f'_data_file_tree("{root}")' in setup_py, (
f'setup.py must include _data_file_tree("{root}") so the wheel '
f"ships {root}/."
)

for root in ("skills", "optional-skills", "optional-mcps"):
assert (REPO_ROOT / root).is_dir() and any(
(REPO_ROOT / root).rglob("*")
), f"expected {root}/ to exist on disk"


# ---------------------------------------------------------------------------
# Dependency-pin consistency: pyproject extras <-> tools/lazy_deps.py
#
Expand Down