From e8419f4f7a7c6c9d7ca46799b2d732308bbbf4c2 Mon Sep 17 00:00:00 2001 From: mic1on Date: Sat, 14 Mar 2026 12:17:25 +0800 Subject: [PATCH] feat(dict): add 7 new utility functions - pick: select specified keys from dict - omit: exclude specified keys from dict - get: safely get nested value with dot path - deep_merge: deeply merge multiple dicts - map_keys: transform dict keys - map_values: transform dict values - invert: swap keys and values With comprehensive unit tests (30 test cases) --- src/usepy/dict/__init__.py | 17 +++- src/usepy/dict/deep_merge.py | 41 +++++++++ src/usepy/dict/get.py | 40 +++++++++ src/usepy/dict/invert.py | 25 ++++++ src/usepy/dict/map_keys.py | 25 ++++++ src/usepy/dict/map_values.py | 25 ++++++ src/usepy/dict/omit.py | 27 ++++++ src/usepy/dict/pick.py | 26 ++++++ tests/test_dict_new.py | 155 +++++++++++++++++++++++++++++++++++ 9 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/usepy/dict/deep_merge.py create mode 100644 src/usepy/dict/get.py create mode 100644 src/usepy/dict/invert.py create mode 100644 src/usepy/dict/map_keys.py create mode 100644 src/usepy/dict/map_values.py create mode 100644 src/usepy/dict/omit.py create mode 100644 src/usepy/dict/pick.py create mode 100644 tests/test_dict_new.py diff --git a/src/usepy/dict/__init__.py b/src/usepy/dict/__init__.py index 3b8a3fb..1d44034 100644 --- a/src/usepy/dict/__init__.py +++ b/src/usepy/dict/__init__.py @@ -2,10 +2,25 @@ from .sort_by_key import sort_by_key from .sort_by_value import sort_by_value from .merge_dicts import merge_dicts +from .pick import pick +from .omit import omit +from .get import get +from .deep_merge import deep_merge +from .map_keys import map_keys +from .map_values import map_values +from .invert import invert __all__ = [ "AdDict", "sort_by_key", "sort_by_value", "merge_dicts", -] + # New functions + "pick", + "omit", + "get", + "deep_merge", + "map_keys", + "map_values", + "invert", +] \ No newline at end of file diff --git a/src/usepy/dict/deep_merge.py b/src/usepy/dict/deep_merge.py new file mode 100644 index 0000000..f6a771b --- /dev/null +++ b/src/usepy/dict/deep_merge.py @@ -0,0 +1,41 @@ +from typing import Dict, Any +import copy + + +def deep_merge(*dicts: Dict[str, Any]) -> Dict[str, Any]: + """ + Deeply merges multiple dictionaries. + + Nested dictionaries are merged recursively. Other values are overwritten. + + Args: + *dicts (Dict[str, Any]): The dictionaries to merge. + + Returns: + Dict[str, Any]: A new deeply merged dictionary. + + Examples: + >>> deep_merge({'a': {'b': 1}}, {'a': {'c': 2}}) + {'a': {'b': 1, 'c': 2}} + >>> deep_merge({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) + {'a': 1, 'b': 3, 'c': 4} + >>> deep_merge({'a': [1, 2]}, {'a': [3, 4]}) + {'a': [3, 4]} + """ + if not dicts: + return {} + + result = copy.deepcopy(dicts[0]) + + for d in dicts[1:]: + for key, value in d.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = deep_merge(result[key], value) + else: + result[key] = copy.deepcopy(value) + + return result \ No newline at end of file diff --git a/src/usepy/dict/get.py b/src/usepy/dict/get.py new file mode 100644 index 0000000..9e64dd8 --- /dev/null +++ b/src/usepy/dict/get.py @@ -0,0 +1,40 @@ +from typing import TypeVar, Dict, Any, Optional, Union, List + +K = TypeVar('K') +V = TypeVar('V') + + +def get(d: Dict[K, Any], path: Union[str, List[K]], default: Optional[V] = None) -> Optional[V]: + """ + Safely gets a value from a nested dictionary using a path. + + Args: + d (Dict[K, Any]): The source dictionary. + path (Union[str, List[K]]): The path to the value. Can be a dot-separated string or a list of keys. + default (Optional[V]): The value to return if path doesn't exist. Defaults to None. + + Returns: + Optional[V]: The value at the path, or default if not found. + + Examples: + >>> get({'a': {'b': {'c': 1}}}, 'a.b.c') + 1 + >>> get({'a': {'b': {'c': 1}}}, ['a', 'b', 'c']) + 1 + >>> get({'a': {'b': 1}}, 'a.x.y') + None + >>> get({'a': {'b': 1}}, 'a.x.y', default=0) + 0 + """ + if isinstance(path, str): + keys = path.split('.') + else: + keys = path + + current = d + for key in keys: + if not isinstance(current, dict) or key not in current: + return default + current = current[key] + + return current \ No newline at end of file diff --git a/src/usepy/dict/invert.py b/src/usepy/dict/invert.py new file mode 100644 index 0000000..46647d9 --- /dev/null +++ b/src/usepy/dict/invert.py @@ -0,0 +1,25 @@ +from typing import TypeVar, Dict, Hashable + +K = TypeVar('K', bound=Hashable) +V = TypeVar('V', bound=Hashable) + + +def invert(d: Dict[K, V]) -> Dict[V, K]: + """ + Creates a new dictionary with keys and values swapped. + + If there are duplicate values, the last key wins. + + Args: + d (Dict[K, V]): The source dictionary. + + Returns: + Dict[V, K]: A new dictionary with keys and values swapped. + + Examples: + >>> invert({'a': 1, 'b': 2}) + {1: 'a', 2: 'b'} + >>> invert({'a': 1, 'b': 1}) + {1: 'b'} + """ + return {v: k for k, v in d.items()} \ No newline at end of file diff --git a/src/usepy/dict/map_keys.py b/src/usepy/dict/map_keys.py new file mode 100644 index 0000000..ac231a1 --- /dev/null +++ b/src/usepy/dict/map_keys.py @@ -0,0 +1,25 @@ +from typing import TypeVar, Dict, Callable + +K = TypeVar('K') +V = TypeVar('V') +K2 = TypeVar('K2') + + +def map_keys(d: Dict[K, V], mapper: Callable[[K], K2]) -> Dict[K2, V]: + """ + Creates a new dictionary with keys transformed by a function. + + Args: + d (Dict[K, V]): The source dictionary. + mapper (Callable[[K], K2]): A function that transforms each key. + + Returns: + Dict[K2, V]: A new dictionary with transformed keys. + + Examples: + >>> map_keys({'a': 1, 'b': 2}, str.upper) + {'A': 1, 'B': 2} + >>> map_keys({1: 'a', 2: 'b'}, lambda x: x * 2) + {2: 'a', 4: 'b'} + """ + return {mapper(k): v for k, v in d.items()} \ No newline at end of file diff --git a/src/usepy/dict/map_values.py b/src/usepy/dict/map_values.py new file mode 100644 index 0000000..d08de3d --- /dev/null +++ b/src/usepy/dict/map_values.py @@ -0,0 +1,25 @@ +from typing import TypeVar, Dict, Callable + +K = TypeVar('K') +V = TypeVar('V') +V2 = TypeVar('V2') + + +def map_values(d: Dict[K, V], mapper: Callable[[V], V2]) -> Dict[K, V2]: + """ + Creates a new dictionary with values transformed by a function. + + Args: + d (Dict[K, V]): The source dictionary. + mapper (Callable[[V], V2]): A function that transforms each value. + + Returns: + Dict[K, V2]: A new dictionary with transformed values. + + Examples: + >>> map_values({'a': 1, 'b': 2}, lambda x: x * 2) + {'a': 2, 'b': 4} + >>> map_values({'a': 'x', 'b': 'y'}, str.upper) + {'a': 'X', 'b': 'Y'} + """ + return {k: mapper(v) for k, v in d.items()} \ No newline at end of file diff --git a/src/usepy/dict/omit.py b/src/usepy/dict/omit.py new file mode 100644 index 0000000..ac6518d --- /dev/null +++ b/src/usepy/dict/omit.py @@ -0,0 +1,27 @@ +from typing import TypeVar, Dict, Iterable + +K = TypeVar('K') +V = TypeVar('V') + + +def omit(d: Dict[K, V], keys: Iterable[K]) -> Dict[K, V]: + """ + Creates a new dictionary without the specified keys. + + Args: + d (Dict[K, V]): The source dictionary. + keys (Iterable[K]): The keys to omit. + + Returns: + Dict[K, V]: A new dictionary without the omitted keys. + + Examples: + >>> omit({'a': 1, 'b': 2, 'c': 3}, ['b']) + {'a': 1, 'c': 3} + >>> omit({'a': 1, 'b': 2}, ['x', 'y']) + {'a': 1, 'b': 2} + >>> omit({}, ['a']) + {} + """ + keys_set = set(keys) + return {k: v for k, v in d.items() if k not in keys_set} \ No newline at end of file diff --git a/src/usepy/dict/pick.py b/src/usepy/dict/pick.py new file mode 100644 index 0000000..55ff092 --- /dev/null +++ b/src/usepy/dict/pick.py @@ -0,0 +1,26 @@ +from typing import TypeVar, Dict, Iterable, Any + +K = TypeVar('K') +V = TypeVar('V') + + +def pick(d: Dict[K, V], keys: Iterable[K]) -> Dict[K, V]: + """ + Creates a new dictionary with only the specified keys. + + Args: + d (Dict[K, V]): The source dictionary. + keys (Iterable[K]): The keys to pick. + + Returns: + Dict[K, V]: A new dictionary with only the picked keys. + + Examples: + >>> pick({'a': 1, 'b': 2, 'c': 3}, ['a', 'c']) + {'a': 1, 'c': 3} + >>> pick({'a': 1, 'b': 2}, ['x', 'y']) + {} + >>> pick({}, ['a']) + {} + """ + return {k: d[k] for k in keys if k in d} \ No newline at end of file diff --git a/tests/test_dict_new.py b/tests/test_dict_new.py new file mode 100644 index 0000000..7923bc5 --- /dev/null +++ b/tests/test_dict_new.py @@ -0,0 +1,155 @@ +import pytest +from usepy.dict import pick, omit, get, deep_merge, map_keys, map_values, invert + + +class TestPick: + """Tests for pick() function""" + + def test_pick_existing_keys(self): + """Test picking existing keys""" + assert pick({'a': 1, 'b': 2, 'c': 3}, ['a', 'c']) == {'a': 1, 'c': 3} + + def test_pick_non_existing_keys(self): + """Test picking non-existing keys""" + assert pick({'a': 1, 'b': 2}, ['x', 'y']) == {} + + def test_pick_mixed_keys(self): + """Test picking mixed existing and non-existing keys""" + assert pick({'a': 1, 'b': 2}, ['a', 'x']) == {'a': 1} + + def test_pick_empty_dict(self): + """Test picking from empty dict""" + assert pick({}, ['a']) == {} + + def test_pick_empty_keys(self): + """Test picking with empty keys""" + assert pick({'a': 1, 'b': 2}, []) == {} + + +class TestOmit: + """Tests for omit() function""" + + def test_omit_existing_keys(self): + """Test omitting existing keys""" + assert omit({'a': 1, 'b': 2, 'c': 3}, ['b']) == {'a': 1, 'c': 3} + + def test_omit_non_existing_keys(self): + """Test omitting non-existing keys""" + assert omit({'a': 1, 'b': 2}, ['x', 'y']) == {'a': 1, 'b': 2} + + def test_omit_all_keys(self): + """Test omitting all keys""" + assert omit({'a': 1, 'b': 2}, ['a', 'b']) == {} + + def test_omit_empty_dict(self): + """Test omitting from empty dict""" + assert omit({}, ['a']) == {} + + def test_omit_empty_keys(self): + """Test omitting with empty keys""" + assert omit({'a': 1, 'b': 2}, []) == {'a': 1, 'b': 2} + + +class TestGet: + """Tests for get() function""" + + def test_get_nested_value(self): + """Test getting nested value with dot path""" + assert get({'a': {'b': {'c': 1}}}, 'a.b.c') == 1 + + def test_get_with_list_path(self): + """Test getting value with list path""" + assert get({'a': {'b': {'c': 1}}}, ['a', 'b', 'c']) == 1 + + def test_get_non_existing_path(self): + """Test getting non-existing path returns None""" + assert get({'a': {'b': 1}}, 'a.x.y') is None + + def test_get_with_default(self): + """Test getting with default value""" + assert get({'a': {'b': 1}}, 'a.x.y', default=0) == 0 + + def test_get_root_key(self): + """Test getting root level key""" + assert get({'a': 1, 'b': 2}, 'a') == 1 + + def test_get_empty_path(self): + """Test getting with empty path""" + assert get({'a': 1}, '') is None + + +class TestDeepMerge: + """Tests for deep_merge() function""" + + def test_deep_merge_nested(self): + """Test deep merging nested dicts""" + result = deep_merge({'a': {'b': 1}}, {'a': {'c': 2}}) + assert result == {'a': {'b': 1, 'c': 2}} + + def test_deep_merge_overwrite(self): + """Test deep merge overwrites non-dict values""" + assert deep_merge({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) == {'a': 1, 'b': 3, 'c': 4} + + def test_deep_merge_empty(self): + """Test deep merge with empty dict""" + assert deep_merge({'a': 1}, {}) == {'a': 1} + assert deep_merge({}, {'a': 1}) == {'a': 1} + + def test_deep_merge_multiple(self): + """Test deep merge with multiple dicts""" + result = deep_merge({'a': 1}, {'b': 2}, {'c': 3}) + assert result == {'a': 1, 'b': 2, 'c': 3} + + def test_deep_merge_preserves_original(self): + """Test deep merge doesn't modify original""" + original = {'a': {'b': 1}} + deep_merge(original, {'a': {'c': 2}}) + assert original == {'a': {'b': 1}} + + +class TestMapKeys: + """Tests for map_keys() function""" + + def test_map_keys_upper(self): + """Test mapping keys to uppercase""" + assert map_keys({'a': 1, 'b': 2}, str.upper) == {'A': 1, 'B': 2} + + def test_map_keys_transform(self): + """Test transforming keys""" + assert map_keys({1: 'a', 2: 'b'}, lambda x: x * 2) == {2: 'a', 4: 'b'} + + def test_map_keys_empty(self): + """Test mapping empty dict""" + assert map_keys({}, str.upper) == {} + + +class TestMapValues: + """Tests for map_values() function""" + + def test_map_values_double(self): + """Test mapping values to double""" + assert map_values({'a': 1, 'b': 2}, lambda x: x * 2) == {'a': 2, 'b': 4} + + def test_map_values_upper(self): + """Test mapping values to uppercase""" + assert map_values({'a': 'x', 'b': 'y'}, str.upper) == {'a': 'X', 'b': 'Y'} + + def test_map_values_empty(self): + """Test mapping empty dict""" + assert map_values({}, lambda x: x) == {} + + +class TestInvert: + """Tests for invert() function""" + + def test_invert_simple(self): + """Test inverting simple dict""" + assert invert({'a': 1, 'b': 2}) == {1: 'a', 2: 'b'} + + def test_invert_duplicate_values(self): + """Test inverting with duplicate values (last key wins)""" + assert invert({'a': 1, 'b': 1}) == {1: 'b'} + + def test_invert_empty(self): + """Test inverting empty dict""" + assert invert({}) == {} \ No newline at end of file