diff --git a/README.md b/README.md index 23f3b3ca..d3e6a944 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,16 @@ assert obj == snapshot(include=paths("nested", "nested.key")) The extra "nested" is required, otherwise the nested dictionary will never be searched -- it'd get pruned too early. +To avoid adding duplicate path parts, we provide a convenient `paths_include` which supports a list/tuple instead of a string for each path to match: + +```py +obj = { + "other": False, + "nested": { "key": True } +} +assert obj == snapshot(include=paths_include(["other"], ["nested", "key"])) +``` + #### `extension_class` This is a way to modify how the snapshot matches and serializes your data in a single assertion. diff --git a/src/syrupy/filters.py b/src/syrupy/filters.py index 86470b1c..59d66c8c 100644 --- a/src/syrupy/filters.py +++ b/src/syrupy/filters.py @@ -1,4 +1,10 @@ -from typing import TYPE_CHECKING +from typing import ( + TYPE_CHECKING, + List, + Set, + Tuple, + Union, +) if TYPE_CHECKING: from syrupy.types import ( @@ -8,24 +14,63 @@ ) -def paths(path_string: str, *path_strings: str) -> "PropertyFilter": +def paths(*path_parts: str) -> "PropertyFilter": """ - Factory to create a filter using list of paths + Factory to create a filter using list of path strings. + + This filter does not work well when combined with "include" and + nested paths, since "include" oeprates per key as an object is traversed + for serialization. For nested paths, we must include all parents. To accomplish + this, we provide an alternative "paths_include" filter which does this + automatically. """ + if not path_parts: + raise TypeError("At least 1 path argument is required.") + + parts: Set[str] = set(path_parts) + def path_filter(*, prop: "PropertyName", path: "PropertyPath") -> bool: path_str = ".".join(str(p) for p, _ in (*path, (prop, None))) - return any(path_str == p for p in (path_string, *path_strings)) + return path_str in parts + + return path_filter + + +def paths_include(*path_parts: Union[Tuple[str, ...], List[str]]) -> "PropertyFilter": + """ + Factory to create a filter using list of path tuples. + """ + + if not path_parts: + raise TypeError("At least 1 path argument is required.") + + # "include" operates per key as an object is traversed for serialization. + # This means, if matching a nested path, we must also include all parents. + parts: Set[Tuple[str, ...]] = set() + for path_part in path_parts: + if isinstance(path_part, (list, tuple)): + for idx in range(len(path_part)): + parts.add(tuple(path_part[: idx + 1])) + else: + raise TypeError("Unexpected argument. Expected list/tuple.") + + def path_filter(*, prop: "PropertyName", path: "PropertyPath") -> bool: + path_tuple = tuple(str(p) for p, _ in (*path, (prop, None))) + return path_tuple in parts return path_filter -def props(prop_name: str, *prop_names: str) -> "PropertyFilter": +def props(*prop_names: str) -> "PropertyFilter": """ Factory to create filter using list of props """ + if not prop_names: + raise TypeError("At least 1 prop name is required.") + def prop_filter(*, prop: "PropertyName", path: "PropertyPath") -> bool: - return any(str(prop) == p for p in (prop_name, *prop_names)) + return any(str(prop) == p for p in prop_names) return prop_filter diff --git a/tests/syrupy/extensions/amber/__snapshots__/test_amber_filters.ambr b/tests/syrupy/extensions/amber/__snapshots__/test_amber_filters.ambr index eea00b21..01f9bbfb 100644 --- a/tests/syrupy/extensions/amber/__snapshots__/test_amber_filters.ambr +++ b/tests/syrupy/extensions/amber/__snapshots__/test_amber_filters.ambr @@ -35,6 +35,16 @@ }), }) # --- +# name: test_includes_nested_path + dict({ + 'include-me': False, + 'layer1': dict({ + 'layer2': list([ + True, + ]), + }), + }) +# --- # name: test_only_includes_expected_props dict({ 'date': 'utc', diff --git a/tests/syrupy/extensions/amber/test_amber_filters.py b/tests/syrupy/extensions/amber/test_amber_filters.py index 6e317439..506da80c 100644 --- a/tests/syrupy/extensions/amber/test_amber_filters.py +++ b/tests/syrupy/extensions/amber/test_amber_filters.py @@ -4,12 +4,13 @@ from syrupy.filters import ( paths, + paths_include, props, ) def test_filters_path_noop(): - with pytest.raises(TypeError, match="required positional argument"): + with pytest.raises(TypeError, match="At least 1 path argument is required."): paths() @@ -24,7 +25,7 @@ def test_filters_expected_paths(snapshot): def test_filters_prop_noop(): - with pytest.raises(TypeError, match="required positional argument"): + with pytest.raises(TypeError, match="At least 1 prop name is required."): props() @@ -50,6 +51,17 @@ def test_only_includes_expected_props(snapshot): assert actual == snapshot(include=paths("0", "date", "nested", "nested.id")) +def test_includes_nested_path(snapshot): + actual = { + "ignore-me": True, + "include-me": False, + "layer1": {"layer2": [0, True]}, + } + assert actual == snapshot( + include=paths_include(["include-me"], ["layer1", "layer2", "1"]) + ) + + @pytest.mark.parametrize( "predicate", [paths("exclude_me", "nested.exclude_me"), props("exclude_me")] )