diff --git a/pyproject.toml b/pyproject.toml index c39d868..70b4ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,9 @@ authors = [ { name = "Connor Ferster", email = "connor@structuralpython.com" } ] requires-python = ">=3.10" -dependencies = [] +dependencies = [ + "deepmerge" +] [project.scripts] jsonchain = "jsonchain:main" diff --git a/src/jsonchain/__init__.py b/src/jsonchain/__init__.py index 19231db..91b3dd5 100644 --- a/src/jsonchain/__init__.py +++ b/src/jsonchain/__init__.py @@ -6,11 +6,19 @@ from .io import (load_json, dump_json) -from .envelope import envelope_tree +from .envelope import ( + envelope_tree, + max, + min, + absmax, + absmin +) from .tree import ( compare_tree_values, extract_keys, trim_branches, - retrieve_leaves + retrieve_leaves, + filter_keys, + merge_trees ) from . import tables \ No newline at end of file diff --git a/src/jsonchain/tree.py b/src/jsonchain/tree.py index 30c2dea..544f604 100644 --- a/src/jsonchain/tree.py +++ b/src/jsonchain/tree.py @@ -12,6 +12,7 @@ def compare_tree_values( leaf_b: Hashable, compare_func: Union[str, callable, None], comparison_key: Optional[Hashable]= None, + comparison_label: Optional[Hashable] = None, *args, **kwargs, ) -> dict: @@ -39,6 +40,10 @@ def compare_tree_values( 'comparison_key' is ignored. 'comparison_key': If provided, will serve as the key for the comparison value. If None, then the name of the comparison operator will used instead. + 'comparison_label': If provided, will add an extra nested layer to the resulting + dictionary keyed with 'comparison_label'. This is useful if you are going + to be merging comparison trees and you wish to uniquely identify each + comparison. """ ops = { "div": operator.truediv, @@ -59,19 +64,24 @@ def compare_tree_values( branch_a = trim_branches(subtree_a, levels_a) branch_b = trim_branches(subtree_b, levels_b) - for trunk in branch_a.keys(): value_a = branch_a[trunk] + if trunk not in branch_b: continue value_b = branch_b[trunk] env_acc.setdefault(trunk, {}) - env_acc[trunk].setdefault(leaf_a, value_a) - env_acc[trunk].setdefault(leaf_b, value_b) + if comparison_label is not None: + env_acc[trunk].setdefault(comparison_label, {}) + compare_acc = env_acc[trunk][comparison_label] + else: + compare_acc = env_acc[trunk] + compare_acc.setdefault(leaf_a, value_a) + compare_acc.setdefault(leaf_b, value_b) comparison_operator = ops.get(compare_func, compare_func) if comparison_operator is not None: compared_value = comparison_operator(value_a, value_b) if comparison_key is None: comparison_key = comparison_operator.__name__ - env_acc[trunk].setdefault(comparison_key, compared_value) + compare_acc.setdefault(comparison_key, compared_value) return env_acc @@ -180,7 +190,50 @@ def extract_keys( return acc - + +def filter_keys( + tree: dict, + include_keys: Optional[list[str]] = None, + exclude_keys: Optional[list[str]] = None, + include_keys_startswith: Optional[str | list[str]] = None + ) -> dict: + """ + Returns a copy of 'tree' that has had some of its top-level + keys removed based on the applied filters. + + Filters: + + - include_keys: Provide a list of keys to include + - exclude_keys: Provide a list of keys to exclude + - include_keys_startswith: Provide a substring that + occurs at the start of the keys. All matches will + be included. If a list of substrings are provided, + then all substrings will be checked for a match + and included. + + These filters are additive and are applied in the following + order: + + include_keys + include_keys_startswith + exclude_keys + """ + include_keys = include_keys or [] + exclude_keys = exclude_keys or [] + if include_keys_startswith is not None and isinstance(include_keys_startswith, str): + include_keys_startswith = [include_keys_startswith] + filtered_keys = [] + for key in tree.keys(): + if key in exclude_keys: continue + if include_keys_startswith is not None: + for include_startswith in include_keys_startswith: + if key.startswith(include_startswith): + filtered_keys.append(key) + elif key in include_keys: + filtered_keys.append(key) + filtered_tree = {k: v for k, v in tree.items() if k in filtered_keys} + return filtered_tree + def merge_trees(trees: list[dict[str, dict]]) -> dict[str, dict]: """ diff --git a/uv.lock b/uv.lock index 7cbeb80..ed7dcc4 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475 }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -33,8 +42,11 @@ wheels = [ [[package]] name = "jsonchain" -version = "0.1.0" +version = "0.2.1" source = { editable = "." } +dependencies = [ + { name = "deepmerge" }, +] [package.dev-dependencies] dev = [ @@ -42,6 +54,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "deepmerge" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8.4.1" }]