# Copyright Kevin Deldycke <kevin@deldycke.com> 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.
"""Test the generic helpers of the private utility module."""
from __future__ import annotations
import copy
import os
from configparser import ConfigParser
from pathlib import Path
import pytest
from click_extra._utils import (
BOOLEAN_STATES,
GENERATOR_TAG,
deep_merge,
exception_chain,
flatten,
missing_extra_message,
parse_bool,
patch_attr,
remove_blanks,
set_nested,
shrinkuser,
walk_up,
)
from click_extra.param_tools import ParamStructure
[docs]
def test_exception_chain_ends_on_a_loop():
"""Two exceptions naming each other are each yielded once."""
spill = ValueError("spill")
alarm = RuntimeError("alarm")
alarm.__context__ = spill
spill.__context__ = alarm
assert list(exception_chain(alarm)) == [alarm, spill]
[docs]
def test_exception_chain_follows_the_context_without_a_cause():
spill = ValueError("spill")
alarm = RuntimeError("alarm")
alarm.__context__ = spill
assert list(exception_chain(alarm)) == [alarm, spill]
[docs]
def test_exception_chain_of_nothing_is_empty():
assert list(exception_chain(None)) == []
[docs]
def test_exception_chain_prefers_the_cause():
"""The cause an exception was raised from wins over the one it replaced."""
spill = ValueError("spill")
burn = KeyError("burn")
alarm = RuntimeError("alarm")
alarm.__context__ = burn
alarm.__cause__ = spill
assert list(exception_chain(alarm)) == [alarm, spill]
[docs]
def test_generator_tag():
assert GENERATOR_TAG == "Click Extra"
[docs]
def test_patch_attr_restores_on_exit():
class Kettle:
temperature = "cold"
kettle = Kettle()
with patch_attr(kettle, "temperature", "boiling"):
assert kettle.temperature == "boiling"
assert kettle.temperature == "cold"
[docs]
def test_patch_attr_restores_on_error():
class Kettle:
temperature = "cold"
kettle = Kettle()
with pytest.raises(RuntimeError): # noqa: SIM117
with patch_attr(kettle, "temperature", "boiling"):
raise RuntimeError("burner failure")
assert kettle.temperature == "cold"
[docs]
def test_patch_attr_requires_existing_attribute():
class Kettle:
pass
with pytest.raises(AttributeError), patch_attr(Kettle(), "temperature", "boiling"):
pass
[docs]
def test_flatten_collapses_nested_iterables():
"""Every nesting level is walked, strings and bytes staying whole."""
nested = [1, (2, [3, {4}]), "ab", b"cd", [[]], ()]
assert list(flatten(nested)) == [1, 2, 3, 4, "ab", b"cd"]
[docs]
def test_flatten_of_nothing_is_empty():
assert list(flatten(())) == []
[docs]
def test_shrinkuser_spells_the_home_directory_as_a_tilde():
home = Path.home()
assert shrinkuser(home) == "~"
assert shrinkuser(home / "notes" / "todo.txt") == str(
Path("~", "notes", "todo.txt")
)
# A sibling whose name merely starts with the home directory's is left alone.
assert shrinkuser(f"{home}1") == f"{home}1"
assert shrinkuser(Path("srv", "www")) == os.path.join("srv", "www")
[docs]
def test_shrinkuser_takes_another_home_spelling():
home = Path.home()
assert shrinkuser(home, home="$HOME") == "$HOME"
assert shrinkuser(home / "notes", home="$HOME") == os.path.join("$HOME", "notes")
[docs]
def test_deep_merge_merges_dicts_and_takes_every_other_value_from_the_overlay():
base = {"a": {"b": 1, "c": [1, 2], "d": {"e": 1}}, "f": "base", "g": {"h": 1}}
overlay = {"a": {"b": 2, "c": [3]}, "f": {"i": 1}, "j": None}
assert deep_merge(base, overlay) == {
"a": {"b": 2, "c": [3], "d": {"e": 1}},
"f": {"i": 1},
"g": {"h": 1},
"j": None,
}
[docs]
def test_deep_merge_leaves_both_arguments_alone():
base = {"a": {"b": 1}}
overlay = {"a": {"c": 2}}
merged = deep_merge(base, overlay)
merged["a"]["d"] = 3
assert base == {"a": {"b": 1}}
assert overlay == {"a": {"c": 2}}
SET_NESTED_PATHS: tuple[tuple[tuple[str, ...], object], ...] = (
(("orchard", "apple", "count"), 3),
(("orchard", "apple", "color"), "red"),
(("orchard", "pear"), {"count": 1}),
(("orchard", "pear"), {"color": "green"}),
(("orchard", "pear", "count"), 2),
(("garden",), "tomato"),
(("garden", "basil"), True),
(("orchard", "apple"), ["fuji", "gala"]),
(("orchard", "apple", "count"), 4),
(("barn",), {"hay": {"bales": 10}}),
(("barn", "hay"), {"straw": 2}),
)
"""Paths and values covering every case of a merge: a fresh path, a shared prefix,
two dictionaries merging, a dictionary replacing a value, and the reverse."""
[docs]
def test_set_nested_matches_merging_one_path_trees():
"""Setting each path in place builds the tree the one-path merges built."""
merged: dict = {}
nested: dict = {}
# Copies, since set_nested changes in place a dictionary it stored as a value.
for keys, value in copy.deepcopy(SET_NESTED_PATHS):
merged = deep_merge(
merged, ParamStructure.init_tree_dict(*keys, leaf=copy.deepcopy(value))
)
set_nested(nested, keys, value)
assert nested == merged
assert nested == {
"orchard": {
"apple": {"count": 4},
"pear": {"count": 2, "color": "green"},
},
"garden": {"basil": True},
"barn": {"hay": {"bales": 10, "straw": 2}},
}
[docs]
def test_set_nested_rejects_an_empty_path():
with pytest.raises(ValueError):
set_nested({}, (), "apple")
[docs]
def test_walk_up_starts_at_the_start_and_ends_at_the_root(tmp_path):
start = tmp_path / "orchard" / "row"
walked = list(walk_up(start))
assert walked[:3] == [start, start.parent, tmp_path]
assert walked[-1] == Path(start.anchor)
assert len(walked) == len(start.parents) + 1
[docs]
@pytest.mark.parametrize(
("text", "expected"),
(
*((spelling, state) for spelling, state in BOOLEAN_STATES.items()),
("TRUE", True),
("Off", False),
(" yes ", True),
("\t0\n", False),
("", None),
("2", None),
("maybe", None),
),
)
def test_parse_bool(text, expected):
"""The spellings read the same whatever their case and surrounding blanks."""
assert parse_bool(text) is expected
[docs]
def test_boolean_states_match_configparser():
"""The copy holds the spellings the standard library reads, no more."""
assert BOOLEAN_STATES == ConfigParser.BOOLEAN_STATES
BLANK_TREE: dict[str, object] = {
"city": "Lyon",
"river": "",
"lake": None,
"forecast": {"rain": None, "wind": {}, "sky": ""},
"harbor": {"boats": 0, "docks": [], "flags": False},
"orchard": {},
}
"""A tree holding a blank value of each kind, at the top and one level down."""
[docs]
@pytest.mark.parametrize(
("kwargs", "expected"),
(
pytest.param(
{},
{"city": "Lyon", "harbor": {"boats": 0, "docks": [], "flags": False}},
id="every-blank",
),
pytest.param(
{"remove_none": False},
{
"city": "Lyon",
"lake": None,
"forecast": {"rain": None},
"harbor": {"boats": 0, "docks": [], "flags": False},
},
id="keep-none",
),
pytest.param(
{"remove_dicts": False},
{
"city": "Lyon",
"forecast": {"wind": {}},
"harbor": {"boats": 0, "docks": [], "flags": False},
"orchard": {},
},
id="keep-dicts",
),
pytest.param(
{"remove_str": False},
{
"city": "Lyon",
"river": "",
"forecast": {"sky": ""},
"harbor": {"boats": 0, "docks": [], "flags": False},
},
id="keep-strings",
),
pytest.param(
{"remove_none": False, "remove_dicts": False, "remove_str": False},
BLANK_TREE,
id="keep-everything",
),
),
)
def test_remove_blanks(kwargs, expected):
"""Each kind of blank value goes, unless its `remove_*` argument keeps it.
A falsy value that is not blank, like `0`, `False` or an empty list, stays.
"""
assert remove_blanks(BLANK_TREE, **kwargs) == expected
[docs]
def test_remove_blanks_drops_a_dict_emptied_by_the_cleaning():
tree = {"garden": {"shed": {"tools": None}}, "fence": "wood"}
assert remove_blanks(tree) == {"fence": "wood"}
assert remove_blanks(tree, remove_dicts=False) == {
"garden": {"shed": {}},
"fence": "wood",
}
[docs]
def test_remove_blanks_leaves_the_tree_alone():
tree = {"garden": {"shed": None, "pond": "koi"}}
cleaned = remove_blanks(tree)
cleaned["garden"]["bench"] = "oak"
assert tree == {"garden": {"shed": None, "pond": "koi"}}