# 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.overrides.<id>]` configuration section.
"""
from __future__ import annotations
import urllib.parse
from dataclasses import fields
from pathlib import Path
from textwrap import dedent
import click
import pytest
import tomli_w
from click_extra import ValidationError, strip_ansi
from meta_package_manager.config import (
AUTO_UPDATES_REPLACEMENT,
CONTRIBUTION_HINT_FIELDS,
INVALIDATED_CACHED_PROPS,
MAX_ISSUE_URL_LENGTH,
RISKY_OVERRIDE_FIELDS,
ContributionHint,
MpmConfig,
_build_issue_url,
_warn_risky_overrides_from_untrusted_source,
apply_manager_overrides,
dump_manager_overrides,
format_contribution_hints,
validate_manager_overrides_section,
)
from meta_package_manager.cooldown import CooldownPolicy
from meta_package_manager.definitions import (
OVERRIDABLE_FIELDS,
OVERRIDES_SECTION,
buildable_operations,
parse_operation_overrides,
)
from meta_package_manager.manager import PackageManager
from meta_package_manager.pool import pool
from .conftest import all_manager_ids, tomllib
from .test_manager_definition import skip_windows
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, "operation_extra_args"):
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.
"""
for field in OVERRIDABLE_FIELDS:
assert hasattr(PackageManager, field), (
f"OVERRIDABLE_FIELDS lists {field!r} but PackageManager has no such "
"attribute"
)
[docs]
@pytest.mark.parametrize("section", (None, {}), ids=("none", "empty"))
def test_empty_section_is_noop(reset_overrides, section):
"""An absent or empty `[mpm.overrides]` section applies and validates as a no-op."""
apply_manager_overrides(pool, section)
validate_manager_overrides_section(section, pool=pool)
assert OVERRIDE_TARGET not in pool.overridden_fields
[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]
@pytest.mark.parametrize(
("field", "value", "message"),
(
pytest.param("made_up_field", 99, r"unknown field", id="unknown-field"),
pytest.param(
"cli_search_path",
"/single/path",
r"expected a list of strings",
id="string-list-given-a-string",
),
pytest.param(
"cli_search_path",
["/ok", 42],
r"expected all entries to be strings",
id="string-list-given-a-number",
),
pytest.param("stop_on_error", 1, r"expected a boolean", id="boolean-given-1"),
# `bool` subclasses `int` in Python, yet a boolean is no integer override.
pytest.param("timeout", True, r"expected an integer", id="integer-given-true"),
pytest.param("timeout", "42", r"expected an integer", id="integer-given-text"),
pytest.param("requirement", 23, r"expected a string", id="string-given-23"),
pytest.param("sudo", 1, r"expected a boolean", id="sudo-given-1"),
pytest.param(
"extra_env",
{"K": 1},
r"expected a table of string-to-string",
id="table-given-a-number-value",
),
pytest.param(
"extra_env",
["PIP_INDEX_URL=https://example.test"],
r"expected a table of string-to-string",
id="table-given-a-list",
),
pytest.param(
"cooldown_policy",
"strict",
r"expected one of enforce, best-effort, off",
id="policy-given-an-unknown-keyword",
),
pytest.param(
"cooldown_policy",
True,
r"expected one of enforce, best-effort, off",
id="policy-given-a-boolean",
),
),
)
def test_override_rejects(reset_overrides, field, value, message):
"""An unknown field or a value of the wrong type stops the override.
The error names what the field accepts.
"""
with pytest.raises(ValidationError, match=message):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {field: value}})
[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]
@pytest.mark.parametrize(
("field", "value", "expected"),
(
pytest.param("stop_on_error", True, True, id="boolean"),
pytest.param("timeout", 42, 42, id="integer"),
pytest.param("requirement", ">=20.0", ">=20.0", id="string"),
pytest.param("sudo", True, True, id="sudo"),
# The policy keyword is read like the `--cooldown` option reads it,
# whatever its case.
pytest.param(
"cooldown_policy",
"Best-Effort",
CooldownPolicy.best_effort,
id="policy-in-any-case",
),
),
)
def test_override_applies(reset_overrides, field, value, expected):
"""An override of the right type sets the field on the manager.
As the type the field holds.
"""
apply_manager_overrides(pool, {OVERRIDE_TARGET: {field: value}})
applied = getattr(pool[OVERRIDE_TARGET], field)
assert applied == expected
assert type(applied) is type(expected)
[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_adds_to_shipped_variables():
"""An `extra_env` override adds its variables to those the manager ships.
Its value wins for a name both set. Each application starts again from the shipped
variables, so a name a later configuration drops is gone.
"""
manager = pool["cask"]
shipped = dict(type(manager).extra_env)
try:
apply_manager_overrides(
pool,
{"cask": {"extra_env": {"HOMEBREW_NO_ANALYTICS": "0", "LANG": "C"}}},
)
assert manager.extra_env == {
**shipped,
"HOMEBREW_NO_ANALYTICS": "0",
"LANG": "C",
}
assert manager.extra_env["HOMEBREW_NO_AUTO_UPDATE"] == "1"
apply_manager_overrides(pool, {"cask": {"extra_env": {"TZ": "UTC"}}})
assert manager.extra_env == {**shipped, "TZ": "UTC"}
finally:
manager.__dict__.pop("extra_env", None)
pool.overridden_fields.pop("cask", None)
[docs]
def test_multiple_fields_in_one_call(reset_overrides):
apply_manager_overrides(
pool,
{
OVERRIDE_TARGET: {
"cli_search_path": ["/x"],
"timeout": 60,
"stop_on_error": True,
}
},
)
manager = pool[OVERRIDE_TARGET]
assert manager.cli_search_path == ("/x",)
assert manager.timeout == 60
assert manager.stop_on_error is True
[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]
@pytest.mark.parametrize(
("field", "override", "global_value"),
(
pytest.param("timeout", 42, 999, id="timeout"),
pytest.param("stop_on_error", True, False, id="stop-on-error"),
# A manager can opt out of escalation, or into it, whatever the global
# `--sudo`.
pytest.param("sudo", False, True, id="sudo"),
),
)
def test_per_manager_override_beats_global_default(
reset_overrides, field, override, global_value
):
"""A per-manager override survives the global `setattr` loop.
The loop in `_select_managers`: the most specific value wins.
"""
apply_manager_overrides(pool, {OVERRIDE_TARGET: {field: override}})
selected = list(
pool._select_managers(
keep=(OVERRIDE_TARGET,),
drop_not_found=False,
**{field: global_value},
)
)
assert len(selected) == 1
kept = getattr(selected[0], field)
assert kept == override
assert type(kept) is type(override)
[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 probes to the resolved timeout first.
The version-detection probes are bound *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_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_is_a_risky_override_field():
"""An untrusted `sudo = true` override trips the untrusted-source warning.
`[mpm.overrides.<id>] sudo = true` from an untrusted source can run a binary as
root, like pre_cmds.
"""
assert "sudo" in RISKY_OVERRIDE_FIELDS
[docs]
@pytest.mark.parametrize(
("source", "is_url", "trusted", "origin"),
(
pytest.param(None, True, True, "a remote URL", id="remote-url"),
pytest.param(
Path("/etc/mpm/config.toml"),
False,
False,
f"untrusted file {Path('/etc/mpm/config.toml')}",
id="unsafe-file",
),
pytest.param(Path("/etc/mpm/config.toml"), False, True, None, id="safe-file"),
pytest.param(None, False, True, None, id="no-config"),
),
)
def test_risky_override_from_untrusted_source_warns(
monkeypatch, caplog, source, is_url, trusted, origin
):
"""One warning per shipped manager whose override sets a risky field.
When the configuration comes from a remote URL or an unsafe file.
The source and its trust are stubbed, so the test reads the same on every
platform. An unknown ID and a section that is not a table carry no field to
warn about.
"""
monkeypatch.setattr(
"meta_package_manager.config._config_source", lambda ctx: (source, is_url)
)
monkeypatch.setattr(
"meta_package_manager.config.config_file_is_trusted", lambda path: trusted
)
_warn_risky_overrides_from_untrusted_source(
click.Context(click.Command("mpm")),
pool,
{
OVERRIDE_TARGET: {
"pre_cmds": ["env"],
"cli_names": ["pip3"],
"timeout": 60,
},
"cask": {"timeout": 60},
"fictional-manager": {"pre_cmds": ["env"]},
"uv": "not a table",
},
)
warnings = [
strip_ansi(record.getMessage())
for record in caplog.records
if "Override of" in record.getMessage()
]
expected = (
f"Override of cli_names, pre_cmds on {OVERRIDE_TARGET} comes from "
f"{origin}; these fields can run arbitrary commands (see docs/security.md)."
)
assert warnings == ([] if origin is None else [expected])
# Per-operation extra arguments.
[docs]
def test_operations_override_reaches_its_command_alone(reset_overrides):
"""Operation arguments reach the commands of their operation alone.
The override, the stamp of the operation method and `build_cli` put them there, and
nowhere else.
"""
apply_manager_overrides(
pool,
{OVERRIDE_TARGET: {"operations": {"upgrade_one": {"extra_args": ["--user"]}}}},
)
manager = pool[OVERRIDE_TARGET]
assert "--user" in manager.upgrade_one_cli("lemon")
assert "--user" not in manager.build_cli("list")
[docs]
@pytest.mark.parametrize(
("operations", "message"),
(
pytest.param("--user", r"expected a table, got str", id="not-a-table"),
pytest.param(
{"install": "--user"}, r"expected a table, got str", id="entry-not-a-table"
),
pytest.param(
{"upgrade_all": {"extra_args": ["--user"]}},
r"builds no upgrade_all command",
id="one-by-one-upgrade",
),
pytest.param(
{"cleanup": {"extra_args": []}},
r"builds no cleanup command",
id="unknown-operation",
),
pytest.param(
{"install": {"args": ["install"]}},
r"unknown key\. Allowed: extra_args",
id="redefinition",
),
pytest.param(
{"install": {"extra_args": "--user"}},
r"expected a list of strings",
id="bare-string",
),
),
)
def test_operations_override_rejects(reset_overrides, operations, message):
with pytest.raises(ValidationError, match=message):
apply_manager_overrides(pool, {OVERRIDE_TARGET: {"operations": operations}})
[docs]
def test_removed_auto_updates_field_names_replacement(reset_overrides):
with pytest.raises(ValidationError, match=r"removed field") as raised:
apply_manager_overrides(pool, {"cask": {"ignore_auto_updates": False}})
assert AUTO_UPDATES_REPLACEMENT in str(raised.value)
[docs]
def test_unknown_manager_with_override_operations_reads_as_typo():
"""A section whose operations only add arguments is a mistyped override.
Not a definition missing its platforms.
"""
with pytest.raises(ValidationError, match=r"unknown manager ID 'caks'"):
apply_manager_overrides(
pool,
{"caks": {"operations": {"upgrade_all": {"extra_args": ["--greedy"]}}}},
)
CONFIG_TEMPLATE = dedent("""\
[mpm.overrides.{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 `[mpm.overrides.<id>]` overrides."""
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]
@skip_windows
def test_cli_warns_on_risky_override_from_untrusted_file(
invoke, create_config, reset_overrides
):
"""The CLI warns about a risky override read from a file others can write to."""
conf_path = create_config(
"conf.toml", CONFIG_TEMPLATE.format(manager_id=OVERRIDE_TARGET)
)
conf_path.chmod(0o666)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "Override of cli_search_path on" in result.stderr
assert "comes from untrusted file" in result.stderr
[docs]
def test_cli_warns_on_stale_overrides_section(invoke, create_config, reset_overrides):
"""A table under `[mpm.managers]` is named in a warning and not applied.
That was the old home of the overrides. The table is not dropped in silence.
"""
conf_path = create_config(
"conf.toml",
dedent("""\
[mpm.managers.pip]
cli_search_path = ["/integration/test/path"]
[mpm.managers.cask]
timeout = 60
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "`[mpm.managers.<id>]` moved to `[mpm.overrides.<id>]`" in result.stderr
assert "These sections are ignored: cask, pip." in result.stderr
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.overrides.fictional-manager]
cli_search_path = ["/x"]
"""),
)
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code != 0
assert "mpm.overrides.fictional-manager" in result.stderr
assert "unknown manager ID" in result.stderr
[docs]
def test_cli_include_auto_updates_names_replacement(invoke):
"""The removed flag for auto-updating casks stops the run.
It names the configuration that replaces it.
"""
result = invoke("--include-auto-updates", "managers")
assert result.exit_code == 2
assert "--include-auto-updates was removed" in result.stderr
assert AUTO_UPDATES_REPLACEMENT in result.stderr
[docs]
def test_cli_ignore_auto_updates_warns(invoke):
"""The removed flag that asked for the default only warns."""
result = invoke("--ignore-auto-updates", "managers")
assert result.exit_code == 0
assert "--ignore-auto-updates was removed and has no effect" in result.stderr
[docs]
@pytest.mark.parametrize(
("value", "hint"),
(
pytest.param("false", AUTO_UPDATES_REPLACEMENT, id="included"),
pytest.param("true", "stay out by default", id="default"),
),
)
def test_cli_removed_auto_updates_key_warns(invoke, create_config, value, hint):
"""click-extra drops the removed `[mpm]` key in silence, so mpm warns."""
conf_path = create_config("conf.toml", f"[mpm]\nignore_auto_updates = {value}\n")
result = invoke("--config", str(conf_path), "managers")
assert result.exit_code == 0
assert "`[mpm] ignore_auto_updates` is ignored" in result.stderr
assert hint 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) generates no hint.
It is 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):
"""Several detection-related overrides on one manager make one hint each.
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 trips 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():
"""The URL-encoded body decodes back to text naming the override.
Round-trip: it mentions the manager id, the field, and the user's value.
"""
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)
body = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))[
"bug-description"
]
assert "/opt/homebrew/bin/brew" in body
[docs]
@pytest.mark.parametrize(
("config", "args", "hinted"),
(
# A detection-related override surfaces the hint.
pytest.param(
"""\
[mpm.overrides.pip]
cli_search_path = ["/no/such/path/for/test"]
""",
(),
True,
id="detection-override",
),
# Overriding only a preference field surfaces no hint.
pytest.param(
"""\
[mpm.overrides.pip]
timeout = 42
""",
(),
False,
id="preference-override",
),
# `--no-suggest-contribs` silences the hint.
pytest.param(
"""\
[mpm.overrides.pip]
cli_search_path = ["/no/such/path"]
""",
("--no-suggest-contribs",),
False,
id="opt-out-option",
),
# The `[mpm] suggest_contribs = false` configuration silences it too.
pytest.param(
"""\
[mpm]
suggest_contribs = false
[mpm.overrides.pip]
cli_search_path = ["/no/such/path"]
""",
(),
False,
id="opt-out-configuration",
),
),
)
def test_cli_contribution_hint(
invoke, create_config, reset_overrides, config, args, hinted
):
"""The configuration decides whether the contribution hint is printed.
End to end: the hint and the issue link it carries reach `<stderr>` or not.
"""
conf_path = create_config("conf.toml", dedent(config))
result = invoke("--config", str(conf_path), *args, "managers")
assert result.exit_code == 0
assert ("Detected user override" in result.stderr) is hinted
assert ("issues/new" in result.stderr) is hinted
if hinted:
assert "cli_search_path" 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]
def test_dump_manager_overrides_lists_buildable_operations(reset_overrides):
"""The template names each operation an override can reach.
With its current `extra_args`.
"""
apply_manager_overrides(
pool,
{OVERRIDE_TARGET: {"operations": {"install": {"extra_args": ["--user"]}}}},
)
manager = pool[OVERRIDE_TARGET]
operations = dump_manager_overrides(manager)["operations"]
assert list(operations) == list(buildable_operations(manager))
assert operations["install"] == {"extra_args": ["--user"]}
assert operations["remove"] == {"extra_args": []}
[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():
if field == "operations":
reparsed = parse_operation_overrides(manager_id, manager, raw_value)
original = {
op_name: manager.operation_extra_args.get(op_name, ())
for op_name in buildable_operations(manager)
}
else:
reparsed = OVERRIDABLE_FIELDS[field](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"][OVERRIDES_SECTION]) == ["winget"]
winget = parsed["mpm"][OVERRIDES_SECTION]["winget"]
# Every key present must be an overridable field or the operations table.
assert set(winget).issubset({*OVERRIDABLE_FIELDS, "operations"})
assert set(winget["operations"]) == set(buildable_operations(pool["winget"]))
[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"][OVERRIDES_SECTION]) == {"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"][OVERRIDES_SECTION]) == 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):
"""A `config-template` output fed back through the overrides is a no-op.
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"][OVERRIDES_SECTION])
for field, original in before.items():
assert getattr(manager, field) == original, (
f"{OVERRIDE_TARGET}.{field} changed after round-trip"
)
[docs]
def test_overrides_section_names_a_schema_field():
"""`OVERRIDES_SECTION` and the schema field declaring it opaque agree.
click-extra derives a configuration path from the field name, so renaming one
side alone leaves every reader of the constant looking at a sub-tree the
schema no longer marks opaque, which the strict check then rejects key by key.
"""
assert OVERRIDES_SECTION in {f.name for f in fields(MpmConfig)}