# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for the per-manager override mechanism driven by the
`[mpm.managers.<id>]` configuration section."""
from __future__ import annotations
from textwrap import dedent
import pytest
import tomli_w
from click_extra import ValidationError
from meta_package_manager.config import (
CONTRIBUTION_HINT_FIELDS,
INVALIDATED_CACHED_PROPS,
MAX_ISSUE_URL_LENGTH,
RISKY_OVERRIDE_FIELDS,
ContributionHint,
_build_issue_url,
apply_manager_overrides,
dump_manager_overrides,
format_contribution_hints,
)
from meta_package_manager.definitions import OVERRIDABLE_FIELDS
from meta_package_manager.pool import pool
from .conftest import all_manager_ids, tomllib
OVERRIDE_TARGET = "pip"
"""Manager ID used as a fixture target for override tests.
`pip` is portable across all test platforms and never unmaintained, so its presence in
the pool is stable.
"""
def _clean_override_target() -> None:
"""Pop overridable fields and cached props from the override target."""
manager = pool[OVERRIDE_TARGET]
for field in OVERRIDABLE_FIELDS:
manager.__dict__.pop(field, None)
for prop in INVALIDATED_CACHED_PROPS:
manager.__dict__.pop(prop, None)
pool.overridden_fields.pop(OVERRIDE_TARGET, None)
[docs]
@pytest.fixture
def reset_overrides():
"""Restore the override target's instance state before and after each test.
The `pool` is a module-level singleton: any attribute we shadow on a manager
instance via `apply_manager_overrides()` would leak to the next test. This
fixture pops every overridable field and every cache-invalidated property from
the instance `__dict__`, and clears the pool's overridden-fields tracking dict
so the next test starts from class defaults.
Cleanup runs both before (in case a prior test in the same xdist worker left the
pool dirty) and after (to not pollute subsequent tests).
"""
_clean_override_target()
yield
_clean_override_target()
[docs]
def test_overridable_fields_match_base_attributes():
"""Every overridable field must exist on the `PackageManager` base class.
Drift between {data}`~meta_package_manager.definitions.OVERRIDABLE_FIELDS` and
the base class is silent at runtime (`setattr` happily creates new attributes)
so this test acts as the safety net.
"""
from meta_package_manager.manager import PackageManager
for field in OVERRIDABLE_FIELDS:
assert hasattr(PackageManager, field), (
f"OVERRIDABLE_FIELDS lists {field!r} but PackageManager has no such attribute"
)
[docs]
def test_none_is_noop():
apply_manager_overrides(pool, None)
[docs]
def test_empty_is_noop():
apply_manager_overrides(pool, {})
[docs]
def test_non_dict_top_level_raises():
with pytest.raises(ValidationError, match=r"expected a table"):
apply_manager_overrides(pool, "not a table") # type: ignore[arg-type]
[docs]
def test_unknown_manager_raises():
with pytest.raises(ValidationError, match=r"unknown manager ID"):
apply_manager_overrides(pool, {"definitely-not-a-manager": {"timeout": 99}})
[docs]
def test_non_dict_manager_section_raises():
with pytest.raises(ValidationError, match=r"expected a table"):
apply_manager_overrides(
pool,
{OVERRIDE_TARGET: "not a table"}, # type: ignore[dict-item]
)
[docs]
def test_unknown_field_raises(reset_overrides):
with pytest.raises(ValidationError, match=r"unknown field"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"made_up_field": 99}})
[docs]
def test_str_tuple_override_replaces_default(reset_overrides):
new_path = ("/opt/custom/bin", "/usr/local/special")
apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_search_path": list(new_path)}}
)
assert pool[OVERRIDE_TARGET].cli_search_path == new_path
assert "cli_search_path" in pool[OVERRIDE_TARGET].__dict__
[docs]
def test_str_tuple_override_coerces_list_to_tuple(reset_overrides):
apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_names": ["pip3", "pip", "pip2"]}}
)
assert pool[OVERRIDE_TARGET].cli_names == ("pip3", "pip", "pip2")
assert isinstance(pool[OVERRIDE_TARGET].cli_names, tuple)
[docs]
def test_str_tuple_override_rejects_bare_string(reset_overrides):
with pytest.raises(ValidationError, match=r"expected a list of strings"):
apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_search_path": "/single/path"}}
)
[docs]
def test_str_tuple_override_rejects_non_string_entries(reset_overrides):
with pytest.raises(ValidationError, match=r"expected all entries to be strings"):
apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_search_path": ["/ok", 42]}}
)
[docs]
def test_bool_override(reset_overrides):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"ignore_auto_updates": False}})
assert pool[OVERRIDE_TARGET].ignore_auto_updates is False
[docs]
def test_bool_override_rejects_int(reset_overrides):
with pytest.raises(ValidationError, match=r"expected a boolean"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"ignore_auto_updates": 1}})
[docs]
def test_int_override(reset_overrides):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": 42}})
assert pool[OVERRIDE_TARGET].timeout == 42
[docs]
def test_int_override_rejects_bool(reset_overrides):
"""A boolean is not an acceptable integer override even though `bool` subclasses
`int` in Python."""
with pytest.raises(ValidationError, match=r"expected an integer"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": True}})
[docs]
def test_int_override_rejects_string(reset_overrides):
with pytest.raises(ValidationError, match=r"expected an integer"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": "42"}})
[docs]
def test_str_override(reset_overrides):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"requirement": ">=20.0"}})
assert pool[OVERRIDE_TARGET].requirement == ">=20.0"
[docs]
def test_str_override_rejects_int(reset_overrides):
with pytest.raises(ValidationError, match=r"expected a string"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"requirement": 23}})
[docs]
def test_dict_override(reset_overrides):
apply_manager_overrides(
pool,
{OVERRIDE_TARGET: {"extra_env": {"PIP_INDEX_URL": "https://example.test"}}},
)
assert pool[OVERRIDE_TARGET].extra_env == {"PIP_INDEX_URL": "https://example.test"}
[docs]
def test_dict_override_rejects_non_string_value(reset_overrides):
with pytest.raises(ValidationError, match=r"expected a table of string-to-string"):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"extra_env": {"K": 1}}})
[docs]
def test_multiple_fields_in_one_call(reset_overrides):
apply_manager_overrides(
pool,
{
OVERRIDE_TARGET: {
"cli_search_path": ["/x"],
"timeout": 60,
"ignore_auto_updates": False,
}
},
)
manager = pool[OVERRIDE_TARGET]
assert manager.cli_search_path == ("/x",)
assert manager.timeout == 60
assert manager.ignore_auto_updates is False
[docs]
def test_cached_property_evicted_on_override(reset_overrides):
"""Force a cached_property to be computed, then verify the override evicts it."""
manager = pool[OVERRIDE_TARGET]
# Force computation; the value itself doesn't matter, only that it is now cached.
_ = manager.cli_path
# `cli_path` may or may not have been resolved on this host; what matters is that
# if it WAS computed, the cache entry is cleared so a follow-up access recomputes.
apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_search_path": ["/elsewhere"]}}
)
assert "cli_path" not in manager.__dict__
[docs]
def test_per_manager_timeout_beats_global_default(reset_overrides):
"""A per-manager `timeout` override must survive the global `setattr` loop in
`_select_managers`: the most specific value wins."""
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": 42}})
selected = list(
pool._select_managers(
keep=(OVERRIDE_TARGET,),
drop_not_found=False,
timeout=999,
)
)
assert len(selected) == 1
assert selected[0].timeout == 42
[docs]
@pytest.mark.parametrize(
("override", "expected"),
(
pytest.param(None, 999, id="global-timeout-reaches-detection"),
pytest.param(42, 42, id="per-manager-override-survives-detection"),
),
)
def test_timeout_binds_detection_phase(
reset_overrides, monkeypatch, override, expected
):
"""`_select_managers` binds the version-detection probes to the resolved timeout
*before* `warm_availability` runs them: the global `--timeout` reaches a plain
candidate, while a per-manager override still keeps precedence."""
if override is not None:
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": override}})
# Capture each candidate's timeout at the moment detection would fire.
recorded: dict[str, int | None] = {}
class _StopProbe(Exception):
"""Halt the generator right after the recording point, so the test never
reaches the real availability loop and stays hermetic."""
def fake_warm(managers):
for manager in managers:
recorded[manager.id] = manager.timeout
raise _StopProbe
monkeypatch.setattr("meta_package_manager.pool.warm_availability", fake_warm)
with pytest.raises(_StopProbe):
list(
pool._select_managers(
keep=(OVERRIDE_TARGET,),
drop_not_found=True,
timeout=999,
)
)
assert recorded[OVERRIDE_TARGET] == expected
[docs]
def test_per_manager_ignore_auto_updates_beats_global_default(reset_overrides):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"ignore_auto_updates": False}})
selected = list(
pool._select_managers(
keep=(OVERRIDE_TARGET,),
drop_not_found=False,
ignore_auto_updates=True,
)
)
assert len(selected) == 1
assert selected[0].ignore_auto_updates is False
[docs]
def test_global_default_still_applies_without_override():
"""Sanity: with no per-manager override, the global default reaches the manager."""
selected = list(
pool._select_managers(
keep=("uv",),
drop_not_found=False,
timeout=77,
)
)
assert len(selected) == 1
assert selected[0].timeout == 77
# Reset for following tests.
pool["uv"].__dict__.pop("timeout", None)
[docs]
def test_sudo_override(reset_overrides):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"sudo": True}})
assert pool[OVERRIDE_TARGET].sudo is True
[docs]
def test_sudo_override_rejects_int(reset_overrides):
with pytest.raises(ValidationError):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"sudo": 1}})
[docs]
def test_per_manager_sudo_beats_global_default(reset_overrides):
"""A per-manager `sudo` override survives the global `setattr` loop, so a
manager can opt out of (or into) escalation regardless of the global `--sudo`."""
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"sudo": False}})
selected = list(
pool._select_managers(
keep=(OVERRIDE_TARGET,),
drop_not_found=False,
sudo=True,
)
)
assert len(selected) == 1
assert selected[0].sudo is False
[docs]
def test_sudo_is_a_risky_override_field():
"""`[mpm.managers.<id>] sudo = true` from an untrusted source can run a binary as
root, so it must trip the untrusted-source warning like pre_cmds."""
assert "sudo" in RISKY_OVERRIDE_FIELDS
CONFIG_TEMPLATE = dedent("""\
[mpm.managers.{manager_id}]
cli_search_path = ["/integration/test/path"]
""")
[docs]
def test_cli_loads_manager_overrides(invoke, create_config, reset_overrides):
"""End-to-end: `mpm --config <path>` applies overrides from
`[mpm.managers.<id>]`."""
conf_path = create_config(
"conf.toml", CONFIG_TEMPLATE.format(manager_id=OVERRIDE_TARGET)
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert pool[OVERRIDE_TARGET].cli_search_path == ("/integration/test/path",)
[docs]
def test_cli_fails_on_unknown_manager_in_config(invoke, create_config):
"""A typo'd manager ID in the config aborts the CLI with a precise dotted
path. Permissive warn-and-skip behavior was removed: the same validator
that powers `--validate-config` runs at normal load time."""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm.managers.fictional-manager]
cli_search_path = ["/x"]
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code != 0
assert "mpm.managers.fictional-manager" in result.stderr
assert "unknown manager ID" in result.stderr
# Contribution-hint feature.
[docs]
def test_contribution_hint_fields_subset_of_overridable():
"""The hint allowlist must only name real overridable fields."""
assert CONTRIBUTION_HINT_FIELDS.issubset(OVERRIDABLE_FIELDS)
[docs]
def test_apply_returns_hint_for_detection_field(reset_overrides):
hints = apply_manager_overrides(
pool, {OVERRIDE_TARGET: {"cli_search_path": ["/no/such/path"]}}
)
assert len(hints) == 1
hint = hints[0]
assert hint.manager_id == OVERRIDE_TARGET
assert hint.field == "cli_search_path"
assert hint.user_value == ("/no/such/path",)
[docs]
def test_apply_returns_no_hint_for_preference_field(reset_overrides):
"""Overriding a preference field (timeout) must not generate a hint: it's not
a detection bug signal."""
hints = apply_manager_overrides(pool, {OVERRIDE_TARGET: {"timeout": 42}})
assert hints == []
[docs]
def test_apply_returns_no_hint_when_overrides_empty():
assert apply_manager_overrides(pool, None) == []
assert apply_manager_overrides(pool, {}) == []
[docs]
def test_apply_raises_on_unknown_manager_so_no_hint_emitted():
"""Apply aborts on unknown manager IDs via the validator, so no hints leak.
The validator runs before any side effects, so the pool is unchanged."""
with pytest.raises(ValidationError, match=r"unknown manager ID"):
apply_manager_overrides(
pool, {"definitely-not-a-manager": {"cli_search_path": ["/x"]}}
)
[docs]
def test_apply_returns_hints_for_each_detection_field(reset_overrides):
"""Multiple detection-related overrides on the same manager produce one hint
per field."""
hints = apply_manager_overrides(
pool,
{
OVERRIDE_TARGET: {
"cli_search_path": ["/x"],
"requirement": ">=20.0",
"timeout": 42,
}
},
)
fields = {h.field for h in hints}
# Two detection-related, one preference (timeout) excluded.
assert fields == {"cli_search_path", "requirement"}
[docs]
def test_build_issue_url_targets_bug_template():
hint = ContributionHint(
manager_id="winget",
field="cli_search_path",
user_value=("C:\\Users\\foo\\bin",),
detected_cli_path=None,
)
url = _build_issue_url(hint)
assert url.startswith(
"https://github.com/kdeldycke/meta-package-manager/issues/new?"
)
# Pre-fills the bug-report template.
assert "template=bug-report.yml" in url
# Body field is named after the bug-report.yml `id: bug-description`.
assert "bug-description=" in url
# Stays under GitHub's URL length cap.
assert len(url) <= MAX_ISSUE_URL_LENGTH
[docs]
def test_build_issue_url_asserts_on_oversize_url():
"""A pathological override value must trip the URL-length assertion rather
than silently producing a URL that GitHub would truncate."""
# 10 KiB of path entries is well past the 8 KiB cap.
pathological_value = tuple(f"/very/long/path/{i:05d}" for i in range(500))
hint = ContributionHint(
manager_id="winget",
field="cli_search_path",
user_value=pathological_value,
detected_cli_path=None,
)
with pytest.raises(AssertionError, match=r"exceeding the .*-character"):
_build_issue_url(hint)
[docs]
def test_build_issue_url_url_decodes_back_to_useful_body():
"""Round-trip: the URL-encoded body must decode back to text mentioning the
manager id, the field, and the user's value."""
import urllib.parse
hint = ContributionHint(
manager_id="winget",
field="cli_names",
user_value=("winget.exe",),
detected_cli_path=None,
)
url = _build_issue_url(hint)
qs = urllib.parse.urlparse(url).query
params = dict(urllib.parse.parse_qsl(qs))
body = params["bug-description"]
assert "winget" in body
assert "cli_names" in body
assert "winget.exe" in body
assert "not found" in body # detected_cli_path was None
[docs]
def test_build_issue_url_includes_detected_cli_path_when_available():
hint = ContributionHint(
manager_id="brew",
field="cli_search_path",
user_value=("/opt/custom",),
detected_cli_path="/opt/homebrew/bin/brew",
)
url = _build_issue_url(hint)
import urllib.parse
body = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))[
"bug-description"
]
assert "/opt/homebrew/bin/brew" in body
[docs]
def test_cli_prints_contribution_hint(invoke, create_config, reset_overrides):
"""End-to-end: a config with a detection-related override surfaces the hint."""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm.managers.pip]
cli_search_path = ["/no/such/path/for/test"]
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "Detected user override" in result.stderr
assert "cli_search_path" in result.stderr
assert "issues/new" in result.stderr
[docs]
def test_cli_no_hint_for_preference_override(invoke, create_config, reset_overrides):
"""Overriding only a preference field must not surface a hint."""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm.managers.pip]
timeout = 42
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "Detected user override" not in result.stderr
[docs]
def test_cli_opt_out_suppresses_hint(invoke, create_config, reset_overrides):
"""`--no-suggest-contribs` silences the hint output."""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm.managers.pip]
cli_search_path = ["/no/such/path"]
"""),
)
result = invoke(
"--config",
str(conf_path),
"--no-suggest-contribs",
"managers",
)
assert result.exit_code == 0
assert "Detected user override" not in result.stderr
assert "issues/new" not in result.stderr
[docs]
def test_cli_opt_out_via_config(invoke, create_config, reset_overrides):
"""The `[mpm] suggest_contribs = false` config also silences
the hint."""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm]
suggest_contribs = false
[mpm.managers.pip]
cli_search_path = ["/no/such/path"]
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "Detected user override" not in result.stderr
assert "issues/new" not in result.stderr
# dump_manager_overrides + config-template subcommand.
[docs]
def test_dump_manager_overrides_skips_none_values():
"""Attributes whose current value is `None` are omitted from the dump."""
manager = pool[OVERRIDE_TARGET]
# Force one overridable field to None to confirm it gets skipped.
manager.__dict__["requirement"] = None
try:
dumped = dump_manager_overrides(manager)
assert "requirement" not in dumped
finally:
manager.__dict__.pop("requirement", None)
[docs]
def test_dump_manager_overrides_converts_tuples_to_lists():
"""Tuples are converted to lists so `tomli_w` can serialize them."""
manager = pool[OVERRIDE_TARGET]
dumped = dump_manager_overrides(manager)
# cli_names is always set (the metaclass populates it from the manager ID).
assert isinstance(dumped["cli_names"], list)
assert all(not isinstance(v, tuple) for v in dumped.values())
[docs]
def test_dump_manager_overrides_keys_are_alphabetical():
"""The output is canonical: dict insertion order matches sorted field names."""
manager = pool[OVERRIDE_TARGET]
dumped = dump_manager_overrides(manager)
assert list(dumped) == sorted(dumped)
[docs]
@all_manager_ids
def test_config_template_round_trips(manager_id):
"""For every manager, dump → tomli_w → tomllib → converter yields the same
value. Catches schema drift between
{data}`~meta_package_manager.definitions.OVERRIDABLE_FIELDS` converters and
the live attribute types."""
manager = pool[manager_id]
dumped = dump_manager_overrides(manager)
serialized = tomli_w.dumps(dumped)
parsed = tomllib.loads(serialized)
for field, raw_value in parsed.items():
converter = OVERRIDABLE_FIELDS[field]
reparsed = converter(raw_value)
original = getattr(manager, field)
assert reparsed == original, (
f"{manager_id}.{field}: {reparsed!r} != {original!r}"
)
[docs]
def test_cli_config_template_one_manager(invoke):
"""`mpm config-template <id>` produces a single parseable manager section."""
result = invoke("config-template", "winget")
assert result.exit_code == 0
parsed = tomllib.loads(result.stdout)
assert list(parsed["mpm"]["managers"]) == ["winget"]
winget = parsed["mpm"]["managers"]["winget"]
# Every key present must be an overridable field.
assert set(winget).issubset(OVERRIDABLE_FIELDS)
[docs]
def test_cli_config_template_multiple_managers(invoke):
"""`mpm config-template <id1> <id2>` dumps each requested manager."""
result = invoke("config-template", "winget", "pip")
assert result.exit_code == 0
parsed = tomllib.loads(result.stdout)
assert set(parsed["mpm"]["managers"]) == {"winget", "pip"}
[docs]
def test_cli_config_template_no_args_dumps_all_maintained(invoke):
"""With no positional args, every maintained manager appears."""
result = invoke("config-template")
assert result.exit_code == 0
parsed = tomllib.loads(result.stdout)
assert set(parsed["mpm"]["managers"]) == set(pool.maintained_manager_ids)
[docs]
def test_cli_config_template_unknown_manager_errors(invoke):
result = invoke("config-template", "definitely-not-a-manager")
assert result.exit_code != 0
assert "definitely-not-a-manager" in result.stderr
[docs]
def test_cli_config_template_output_is_applicable(invoke, reset_overrides):
"""End-to-end: pipe `mpm config-template <id>` output back through
`apply_manager_overrides` and confirm the pool is unchanged."""
manager = pool[OVERRIDE_TARGET]
before = {field: getattr(manager, field) for field in OVERRIDABLE_FIELDS}
result = invoke("config-template", OVERRIDE_TARGET)
assert result.exit_code == 0
parsed = tomllib.loads(result.stdout)
apply_manager_overrides(pool, parsed["mpm"]["managers"])
for field, original in before.items():
assert getattr(manager, field) == original, (
f"{OVERRIDE_TARGET}.{field} changed after round-trip"
)