├── tests ├── __init__.py ├── transformer │ ├── __init__.py │ ├── test_transformer.py │ ├── fixtures.py │ ├── test_get_specs.py │ ├── test_core.py │ ├── test_exclusion.py │ └── test_inclusion.py └── test_utils.py ├── .python-version ├── src └── transformd │ ├── strategies │ ├── __init__.py │ ├── exclusion.py │ └── inclusion.py │ ├── __init__.py │ ├── exceptions.py │ ├── utils.py │ └── transformer.py ├── .gitignore ├── requirements.lock ├── CHANGELOG.md ├── requirements-dev.lock ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.13 2 | -------------------------------------------------------------------------------- /tests/transformer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/transformd/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | .venv 4 | .coverage 5 | -------------------------------------------------------------------------------- /src/transformd/__init__.py: -------------------------------------------------------------------------------- 1 | from transformd.exceptions import InvalidSpecError 2 | from transformd.transformer import Transformer 3 | 4 | __all__ = [ 5 | "Transformer", 6 | "InvalidSpecError", 7 | ] 8 | -------------------------------------------------------------------------------- /src/transformd/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidSpecError(Exception): 2 | def __init__(self, key: str, data: dict): 3 | self.key = key 4 | self.data = data 5 | 6 | message = f"'{self.key}' is invalid" 7 | 8 | super().__init__(message) 9 | -------------------------------------------------------------------------------- /src/transformd/utils.py: -------------------------------------------------------------------------------- 1 | def is_int(s: str) -> bool: 2 | """ 3 | Checks whether a string is actually an integer. 4 | """ 5 | 6 | try: 7 | int(s) 8 | except ValueError: 9 | return False 10 | else: 11 | return True 12 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | 9 | -e file:. 10 | mergedeep==1.3.4 11 | typeguard==4.1.5 12 | typing-extensions==4.8.0 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.0 4 | 5 | - Fix: Handle dictionaries where the first key is an array. 6 | 7 | ## 0.2.2 8 | 9 | - Change `|` to `Union` for broader Python support. 10 | 11 | ## 0.2.1 12 | 13 | - Loosen Python requirement to >=3.8. 14 | 15 | ## 0.2.0 16 | 17 | - Add tests. 18 | 19 | ## 0.1.0 20 | 21 | - Initial version. 22 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from transformd.utils import is_int 2 | 3 | 4 | def test_is_int_string(): 5 | actual = is_int("asdf") 6 | 7 | assert actual is False 8 | 9 | 10 | def test_is_int_integer(): 11 | actual = is_int(1) 12 | 13 | assert actual is True 14 | 15 | 16 | def test_is_int_string_integer(): 17 | actual = is_int("1") 18 | 19 | assert actual is True 20 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | 9 | -e file:. 10 | coverage==7.3.2 11 | exceptiongroup==1.1.3 12 | iniconfig==2.0.0 13 | mergedeep==1.3.4 14 | mypy==1.7.0 15 | mypy-extensions==1.0.0 16 | packaging==23.2 17 | pluggy==1.3.0 18 | pytest==7.4.3 19 | pytest-cov==4.1.0 20 | ruff==0.1.5 21 | tomli==2.0.1 22 | typeguard==4.1.5 23 | typing-extensions==4.8.0 24 | -------------------------------------------------------------------------------- /tests/transformer/test_transformer.py: -------------------------------------------------------------------------------- 1 | # Import any fixtures to be used in test functions 2 | from tests.transformer.fixtures import * # noqa: F403 3 | 4 | 5 | def test_both_inclusion_and_exclusion(transformer): 6 | expected = { 7 | "library": { 8 | "books": [ 9 | { 10 | "title": "The Grapes of Wrath", 11 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 12 | }, 13 | ], 14 | } 15 | } 16 | 17 | spec = ( 18 | "library.name", 19 | "library.books.0", 20 | "-library.name", 21 | ) 22 | actual = transformer.transform(spec=spec) 23 | 24 | assert expected == actual 25 | -------------------------------------------------------------------------------- /tests/transformer/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from transformd import Transformer 4 | 5 | 6 | @pytest.fixture 7 | def transformer(): 8 | data = { 9 | "library": { 10 | "name": "Main St Library", 11 | "location": { 12 | "street": "123 Main St", 13 | "city": "New York City", 14 | "state": "NY", 15 | }, 16 | "books": [ 17 | { 18 | "title": "The Grapes of Wrath", 19 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 20 | }, 21 | { 22 | "title": "Slaughterhouse-Five", 23 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 24 | }, 25 | ], 26 | } 27 | } 28 | 29 | return Transformer(data) 30 | -------------------------------------------------------------------------------- /tests/transformer/test_get_specs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typeguard import TypeCheckError 3 | 4 | # Import any fixtures to be used in test functions 5 | from tests.transformer.fixtures import * # noqa: F403 6 | 7 | 8 | def test_string(transformer): 9 | expected = [ 10 | "test", 11 | ] 12 | actual = transformer._get_specs("test") 13 | 14 | assert expected == actual 15 | 16 | 17 | def test_list(transformer): 18 | expected = [ 19 | "test", 20 | ] 21 | actual = transformer._get_specs( 22 | [ 23 | "test", 24 | ] 25 | ) 26 | 27 | assert expected == actual 28 | 29 | 30 | def test_tuple(transformer): 31 | expected = ("test",) 32 | actual = transformer._get_specs(("test",)) 33 | 34 | assert expected == actual 35 | 36 | 37 | def test_invalid_type(transformer): 38 | with pytest.raises(TypeCheckError) as e: 39 | transformer._get_specs(1) 40 | 41 | assert 'argument "spec" (int) did not match any element in the union' in e.exconly() 42 | -------------------------------------------------------------------------------- /tests/transformer/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typeguard import TypeCheckError 3 | 4 | # Import any fixtures to be used in test functions 5 | from tests.transformer.fixtures import * # noqa: F403 6 | from transformd import InvalidSpecError 7 | 8 | 9 | def test_invalid_type(transformer): 10 | with pytest.raises(TypeCheckError) as e: 11 | transformer.transform(spec=1) 12 | 13 | assert 'argument "spec" (int) did not match any element in the union' in e.exconly() 14 | 15 | 16 | def test_invalid_spec(transformer): 17 | with pytest.raises(InvalidSpecError) as e: 18 | transformer.transform(spec="library.missing") 19 | 20 | assert "'missing' is invalid" in e.exconly() 21 | 22 | 23 | def test_invalid_spec_exception_has_data(transformer): 24 | with pytest.raises(InvalidSpecError) as e: 25 | transformer.transform(spec="library.missing") 26 | 27 | expected_key = "missing" 28 | assert expected_key == e.value.key 29 | 30 | expected_data = transformer.data["library"] 31 | assert expected_data == e.value.data 32 | 33 | 34 | def test_invalid_spec_ignore_invalid(transformer): 35 | expected = {"library": {}} 36 | actual = transformer.transform(spec="library.missing", ignore_invalid=True) 37 | 38 | assert expected == actual 39 | 40 | 41 | def test_whole_object_with_string_spec(transformer): 42 | expected = transformer.data.copy() 43 | actual = transformer.transform(spec="library") 44 | 45 | assert expected == actual 46 | 47 | 48 | def test_whole_object_with_tuple_spec(transformer): 49 | expected = transformer.data.copy() 50 | actual = transformer.transform(spec=("library",)) 51 | 52 | assert expected == actual 53 | 54 | 55 | def test_whole_object_with_list_spec(transformer): 56 | expected = transformer.data.copy() 57 | actual = transformer.transform( 58 | spec=[ 59 | "library", 60 | ] 61 | ) 62 | 63 | assert expected == actual 64 | -------------------------------------------------------------------------------- /src/transformd/strategies/exclusion.py: -------------------------------------------------------------------------------- 1 | from transformd.exceptions import InvalidSpecError 2 | from transformd.utils import is_int 3 | 4 | NESTED_PIECES_COUNT = 2 5 | 6 | 7 | def process(data: dict, spec: str, ignore_invalid: bool = False) -> None: 8 | """Handle exclusion specs, i.e. specs that specify which parts of the original dictionary to exclude 9 | from a new dictionary. This will be any spec that starts with a dash. 10 | 11 | Args: 12 | data: The original data. Gets modified in place. 13 | spec: Specifies the data to return. 14 | ignore_invalid: Whether to ignore an invalid spec or not. Raises `InvalidSpecError` 15 | if `False` and the spec is invalid. Defaults to `False`. 16 | """ 17 | 18 | # Break the spec into a list of pieces based on dot notation 19 | pieces = spec.split(".") 20 | 21 | if len(pieces) <= 1: 22 | piece = pieces[0] 23 | 24 | if piece not in data and not ignore_invalid: 25 | raise InvalidSpecError(key=piece, data=data) 26 | 27 | del data[piece] 28 | elif len(pieces) == NESTED_PIECES_COUNT: 29 | (first_piece, second_piece) = pieces 30 | 31 | if first_piece not in data: 32 | raise InvalidSpecError(key=first_piece, data=data) 33 | 34 | if data[first_piece] is not None: 35 | if second_piece in data[first_piece]: 36 | del data[first_piece][second_piece] 37 | elif isinstance(data[first_piece], list) and is_int(second_piece): 38 | list_idx = int(second_piece) 39 | 40 | data[first_piece].pop(list_idx) 41 | elif not ignore_invalid: 42 | raise InvalidSpecError(key=second_piece, data=data) 43 | elif len(pieces) > NESTED_PIECES_COUNT: 44 | next_piece_idx = spec.index(".") + 1 45 | remaining_spec = spec[next_piece_idx:] 46 | 47 | piece = pieces[0] 48 | remaining_data = data[piece] 49 | 50 | if isinstance(remaining_data, list): 51 | remaining_spec_pieces = remaining_spec.split(".") 52 | 53 | if len(remaining_spec_pieces) > 1 and is_int(remaining_spec_pieces[0]): 54 | list_idx = int(remaining_spec_pieces[0]) 55 | 56 | remaining_data = remaining_data[list_idx] 57 | remaining_spec = ".".join(remaining_spec_pieces[1:]) 58 | 59 | process(data=remaining_data, spec=remaining_spec, ignore_invalid=ignore_invalid) 60 | -------------------------------------------------------------------------------- /src/transformd/transformer.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from mergedeep import Strategy, merge 4 | from typeguard import typechecked 5 | 6 | from transformd.strategies import exclusion, inclusion 7 | 8 | 9 | @typechecked 10 | class Transformer: 11 | """Transforms a dictionary based on a `spec`.""" 12 | 13 | def __init__(self, data: dict): 14 | """Creates a new transformer with the `dictionary` data to later be transformed.""" 15 | 16 | self.data = data 17 | 18 | def _get_specs(self, spec: Union[str, tuple[str, ...], list[str]]) -> Union[tuple[str, ...], list[str]]: 19 | """Ensures that the passed-in spec is a list/tuple. 20 | 21 | Args: 22 | spec: The `dictionary` specification. 23 | 24 | Returns: 25 | A `spec` as a list/tuple. 26 | """ 27 | 28 | if isinstance(spec, str): 29 | return [spec] 30 | 31 | return spec 32 | 33 | def transform(self, spec: Union[str, tuple[str, ...], list[str]], ignore_invalid: bool = False) -> dict: 34 | """Transforms a dictionary based on a `spec` to keep the same "shape" of the original dictionary, 35 | but only include the pieces of the dictionary that are specified with dot-notation. 36 | 37 | Args: 38 | spec: Specifies the parts of the `dictionary` to keep. 39 | ignore_invalid: Whether to ignore an invalid spec or not. Raises `InvalidSpecError` 40 | if `False` and the spec is invalid. Defaults to `False`. 41 | 42 | Returns: 43 | A new `dictionary` with the specified shape based on the passed-in `spec`. 44 | """ 45 | 46 | transformed_data = {} 47 | specs = self._get_specs(spec) 48 | 49 | for spec in specs: 50 | if spec.startswith("-"): 51 | exclusion_spec = spec[1:] 52 | excluded_data = transformed_data or self.data 53 | 54 | exclusion.process(data=excluded_data, spec=exclusion_spec, ignore_invalid=ignore_invalid) 55 | 56 | # Overwrite with the excluded data 57 | transformed_data = excluded_data 58 | else: 59 | included_data = inclusion.process(data=self.data, spec=spec, ignore_invalid=ignore_invalid) 60 | 61 | # Specify the additive strategy so that nothing gets clobbered while merging 62 | merge( 63 | transformed_data, 64 | included_data, 65 | strategy=Strategy.ADDITIVE, 66 | ) 67 | 68 | return transformed_data 69 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "transformd" 3 | version = "0.3.0" 4 | description = "Transform dictionaries into whatever your heart desires (as long as it's another dictionary that kind of looks like the original dictionary)" 5 | authors = [ 6 | { name = "Adam Hill" } 7 | ] 8 | classifiers = [ 9 | "Programming Language :: Python :: 3", 10 | "License :: OSI Approved :: MIT License", 11 | "Operating System :: OS Independent", 12 | ] 13 | requires-python = ">= 3.8" 14 | readme = "README.md" 15 | 16 | dependencies = [ 17 | "mergedeep==1.3.4", 18 | "typeguard==4.1.5" 19 | ] 20 | 21 | [project.urls] 22 | "Homepage" = "https://github.com/adamghill/transformd" 23 | 24 | [tool.rye] 25 | managed = true 26 | dev-dependencies = [ 27 | "pytest", 28 | "coverage[toml]>=7.3.2", 29 | "mypy", 30 | "ruff", 31 | "pytest-cov>=4.1.0", 32 | ] 33 | 34 | [tool.rye.scripts] 35 | format = "ruff format ." 36 | t = "pytest" 37 | tc = "pytest --cov=transformd --cov-report term-missing" 38 | 39 | [tool.ruff] 40 | src = ["src/transformd"] 41 | exclude = [] 42 | target-version = "py39" 43 | line-length = 120 44 | select = [ 45 | "A", 46 | "ARG", 47 | "B", 48 | "C", 49 | "DTZ", 50 | "E", 51 | "EM", 52 | "F", 53 | "FBT", 54 | "I", 55 | "ICN", 56 | "ISC", 57 | "N", 58 | "PLC", 59 | "PLE", 60 | "PLR", 61 | "PLW", 62 | "Q", 63 | "RUF", 64 | "S", 65 | "T", 66 | "TID", 67 | "UP", 68 | "W", 69 | "YTT", 70 | ] 71 | ignore = [ 72 | # Allow non-abstract empty methods in abstract base classes 73 | "B027", 74 | # Allow boolean positional values in function calls, like `dict.get(... True)` 75 | "FBT003", 76 | # Ignore checks for possible passwords 77 | "S105", "S106", "S107", 78 | # Ignore complexity 79 | "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", 80 | # Ignore unused variables 81 | "F841", 82 | # Ignore exception strings 83 | "EM101", "EM102", 84 | # Conflicts with formatter 85 | "ISC001" 86 | ] 87 | unfixable = [ 88 | # Don't touch unused imports 89 | "F401", 90 | ] 91 | 92 | [tool.ruff.pydocstyle] 93 | convention = "google" # Accepts: "google", "numpy", or "pep257". 94 | 95 | [tool.ruff.isort] 96 | known-first-party = [ 97 | "transformd", 98 | "tests", 99 | ] 100 | 101 | [tool.ruff.flake8-tidy-imports] 102 | ban-relative-imports = "all" 103 | 104 | [tool.ruff.per-file-ignores] 105 | # Tests can use magic values, assertions, and relative imports 106 | "tests/**/*" = ["PLR2004", "S101", "TID252"] 107 | 108 | [tool.pytest.ini_options] 109 | addopts = "--quiet --failed-first -p no:warnings" 110 | testpaths = [ 111 | "tests" 112 | ] 113 | markers = [ 114 | ] 115 | 116 | [tool.hatch.metadata] 117 | allow-direct-references = true 118 | 119 | [build-system] 120 | requires = ["hatchling"] 121 | build-backend = "hatchling.build" 122 | -------------------------------------------------------------------------------- /src/transformd/strategies/inclusion.py: -------------------------------------------------------------------------------- 1 | from transformd.exceptions import InvalidSpecError 2 | from transformd.utils import is_int 3 | 4 | 5 | def process(data: dict, spec: str, ignore_invalid: bool = False) -> dict: 6 | """Handle inclusion specs, i.e. specs that specify which parts of the original dictionary to include 7 | in a new dictionary. This will be any spec that does not start with a dash. 8 | 9 | Args: 10 | spec: Specifies the data to return. 11 | ignore_invalid: Whether to ignore an invalid spec or not. Raises `InvalidSpecError` 12 | if `False` and the spec is invalid. Defaults to `False`. 13 | 14 | Returns: 15 | A `dictionary` with only the data that was specified in the spec. 16 | """ 17 | 18 | piece_data = {} 19 | new_data = {} 20 | 21 | # Break the spec into a list of pieces based on dot notation 22 | pieces = spec.split(".") 23 | 24 | skip_idx = False 25 | 26 | if len(pieces) == 1: 27 | piece_data = new_data 28 | 29 | for idx, piece in enumerate(pieces): 30 | if skip_idx: 31 | skip_idx = False 32 | continue 33 | 34 | if piece in data and isinstance(data[piece], list) and len(pieces) >= idx + 2 and is_int(pieces[idx + 1]): 35 | # Handle this as referring to a list 36 | if piece not in new_data: 37 | new_data[piece] = [] 38 | 39 | # Handle the nested attribute inside the list 40 | if len(pieces) >= idx + 3: 41 | # Create one object and use index of 0 here because `mergedeep` 42 | # will handle merging the nested lists together later 43 | new_data[piece].append({}) 44 | new_data = new_data[piece][0] 45 | 46 | if idx == 0: 47 | # If the first piece is an array, handle that specially 48 | piece_data.update({piece: [new_data]}) 49 | 50 | # Move the pointer to the object in the list 51 | list_idx = int(pieces[idx + 1]) 52 | data = data[piece][list_idx] 53 | 54 | # Skip the next idx in the list because it specifies the index of the nested list 55 | # which is already handled above 56 | skip_idx = True 57 | elif len(pieces) == idx + 1 and is_int(piece): 58 | # Handles a list index, e.g. `books.0` 59 | 60 | list_piece = pieces[idx - 1] 61 | list_idx = int(piece) 62 | 63 | new_data[list_piece].append(data[list_piece][list_idx]) 64 | elif piece in data: 65 | if idx == len(pieces) - 1: 66 | new_data.update({piece: data[piece]}) 67 | else: 68 | new_data.update({piece: {}}) 69 | 70 | if piece_data == {}: 71 | piece_data.update(new_data) 72 | 73 | new_data = new_data[piece] 74 | data = data[piece] 75 | elif ignore_invalid is False: 76 | raise InvalidSpecError(key=piece, data=data) 77 | 78 | return piece_data 79 | -------------------------------------------------------------------------------- /tests/transformer/test_exclusion.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # Import any fixtures to be used in test functions 4 | from tests.transformer.fixtures import * 5 | from transformd.exceptions import InvalidSpecError 6 | 7 | 8 | def test_invalid(transformer): 9 | expected = "'missing' is invalid" 10 | 11 | with pytest.raises(InvalidSpecError) as e: 12 | transformer.transform(spec="-missing") 13 | 14 | assert expected in e.exconly() 15 | 16 | 17 | def test_invalid_nested(transformer): 18 | expected = "'missing' is invalid" 19 | 20 | with pytest.raises(InvalidSpecError) as e: 21 | transformer.transform(spec="-missing.name") 22 | 23 | assert expected in e.exconly() 24 | 25 | 26 | def test_invalid_nested_2(transformer): 27 | expected = "'missing' is invalid" 28 | 29 | with pytest.raises(InvalidSpecError) as e: 30 | transformer.transform(spec="-library.missing") 31 | 32 | assert expected in e.exconly() 33 | 34 | 35 | def test_whole(transformer): 36 | expected = {} 37 | 38 | actual = transformer.transform(spec="-library") 39 | 40 | assert expected == actual 41 | 42 | 43 | def test_nested(transformer): 44 | expected = { 45 | "library": { 46 | "name": "Main St Library", 47 | "books": [ 48 | { 49 | "title": "The Grapes of Wrath", 50 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 51 | }, 52 | { 53 | "title": "Slaughterhouse-Five", 54 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 55 | }, 56 | ], 57 | } 58 | } 59 | 60 | actual = transformer.transform(spec="-library.location") 61 | 62 | assert expected == actual 63 | 64 | 65 | def test_multiple(transformer): 66 | expected = {"library": {"name": "Main St Library"}} 67 | 68 | spec = ( 69 | "-library.location", 70 | "-library.books", 71 | ) 72 | actual = transformer.transform(spec=spec) 73 | 74 | assert expected == actual 75 | 76 | 77 | def test_list(transformer): 78 | expected = { 79 | "library": { 80 | "books": [ 81 | { 82 | "title": "Slaughterhouse-Five", 83 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 84 | }, 85 | ], 86 | } 87 | } 88 | 89 | spec = ( 90 | "-library.location", 91 | "-library.name", 92 | "-library.books.0", 93 | ) 94 | actual = transformer.transform(spec=spec) 95 | 96 | assert expected == actual 97 | 98 | 99 | def test_list_with_nest(transformer): 100 | expected = { 101 | "library": { 102 | "books": [ 103 | { 104 | "title": "Slaughterhouse-Five", 105 | }, 106 | ], 107 | } 108 | } 109 | 110 | spec = ( 111 | "-library.location", 112 | "-library.name", 113 | "-library.books.0", 114 | "-library.books.0.author", 115 | ) 116 | actual = transformer.transform(spec=spec) 117 | 118 | assert expected == actual 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # transformd 2 | 3 | Transform a `dictionary` to another `dictionary`, but keep the same shape based on a `spec`. 4 | 5 | ## What is a spec? 6 | 7 | It is a string (or sequence of strings) that specifies which "parts" of the dictionary should be included or excluded in a new `dictionary` that is returned from the `transform` function. 8 | 9 | A `spec` uses dot-notation to specify how to traverse into the `dictionary`. It can also use indexes if the value of the `dictionary` is a list. 10 | 11 | Note: `Specs` are applied to the original `dictionary` in the order they are defined. 12 | 13 | ## Examples 14 | 15 | ```python 16 | from transformd import Transformer 17 | 18 | # Initial `dictionary` we'll transform based on a spec below 19 | data = { 20 | "library": { 21 | "name": "Main St Library", 22 | "location": { 23 | "street": "123 Main St", 24 | "city": "New York City", 25 | "state": "NY", 26 | }, 27 | "books": [ 28 | { 29 | "title": "The Grapes of Wrath", 30 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 31 | }, 32 | { 33 | "title": "Slaughterhouse-Five", 34 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 35 | }, 36 | ], 37 | } 38 | } 39 | 40 | # Only return a nested part of `data` 41 | assert Transformer(data).transform(spec="library.name") == { 42 | "library": { 43 | "name": "Main St Library" 44 | } 45 | } 46 | 47 | # Return multiple parts of `data` 48 | assert Transformer(data).transform(spec=("library.name", "library.location.state")) == { 49 | "library": { 50 | "name": "Main St Library", 51 | "location": { 52 | "state": "NY" 53 | }, 54 | } 55 | } 56 | 57 | # Return different parts of a nested list in `data` 58 | assert Transformer(data).transform(spec=("library.books.0.title", "library.books.1")) == { 59 | "library": { 60 | "books": [ 61 | { 62 | "title": "The Grapes of Wrath", 63 | }, 64 | { 65 | "title": "Slaughterhouse-Five", 66 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 67 | }, 68 | ], 69 | } 70 | } 71 | 72 | # Exclude pieces from `data` by prefixing a spec with a dash 73 | assert Transformer(data).transform(spec=("-library.books", "-library.location")) == { 74 | "library": { 75 | "name": "Main St Library" 76 | } 77 | } 78 | ``` 79 | 80 | ## Why? 81 | 82 | I needed this functionality for [`Unicorn`](https://www.django-unicorn.com), but could not find a suitable library. After writing the code, I thought maybe it would be useful for someone else. 😎 83 | 84 | ## Run tests 85 | 86 | - Install [rye](https://rye-up.com) 87 | - `rye sync` 88 | - `rye run t` 89 | 90 | ### Test Coverage 91 | 92 | - `rye run tc` 93 | 94 | ## Inspiration 95 | 96 | - Django Templates for the dot-notation inspiration 97 | - A lot of existing JSON-related tools, but especially [`glom`](https://glom.readthedocs.io/), [`jello`](https://github.com/kellyjonbrazil/jello), [`jq`](https://jqlang.github.io/jq/), and [`gron`](https://github.com/TomNomNom/gron); all of which did not quite do what I wanted, but were useful on the journey 98 | -------------------------------------------------------------------------------- /tests/transformer/test_inclusion.py: -------------------------------------------------------------------------------- 1 | # Import any fixtures to be used in test functions 2 | from tests.transformer.fixtures import * # noqa: F403 3 | from transformd import Transformer 4 | 5 | 6 | def test_nested(transformer): 7 | expected = { 8 | "library": { 9 | "location": { 10 | "street": "123 Main St", 11 | "city": "New York City", 12 | "state": "NY", 13 | }, 14 | } 15 | } 16 | 17 | spec = ("library.location",) 18 | actual = transformer.transform(spec=spec) 19 | 20 | assert expected == actual 21 | 22 | 23 | def test_nested_multiple(transformer): 24 | expected = {"library": {"location": {"street": "123 Main St"}}} 25 | 26 | spec = ("library.location.street",) 27 | actual = transformer.transform(spec=spec) 28 | 29 | assert expected == actual 30 | 31 | 32 | def test_multiple_nested(transformer): 33 | expected = { 34 | "library": { 35 | "name": "Main St Library", 36 | "location": { 37 | "state": "NY", 38 | }, 39 | } 40 | } 41 | 42 | spec = ( 43 | "library.name", 44 | "library.location.state", 45 | ) 46 | actual = transformer.transform(spec=spec) 47 | 48 | assert actual == expected 49 | 50 | 51 | def test_multiple_overridden(transformer): 52 | expected = { 53 | "library": { 54 | "location": { 55 | "street": "123 Main St", 56 | "city": "New York City", 57 | "state": "NY", 58 | } 59 | } 60 | } 61 | 62 | spec = ( 63 | "library.location", 64 | "library.location.street", 65 | ) 66 | actual = transformer.transform(spec=spec) 67 | 68 | assert actual == expected 69 | 70 | 71 | def test_multiple_overridden_2(transformer): 72 | expected = { 73 | "library": { 74 | "location": { 75 | "street": "123 Main St", 76 | "city": "New York City", 77 | "state": "NY", 78 | } 79 | } 80 | } 81 | 82 | spec = ( 83 | "library.location.street", 84 | "library.location", 85 | ) 86 | actual = transformer.transform(spec=spec) 87 | 88 | assert actual == expected 89 | 90 | 91 | def test_list(transformer): 92 | expected = { 93 | "library": { 94 | "books": [ 95 | { 96 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 97 | }, 98 | { 99 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 100 | }, 101 | ], 102 | } 103 | } 104 | 105 | spec = ( 106 | "library.books.0.author", 107 | "library.books.1.author", 108 | ) 109 | actual = transformer.transform(spec=spec) 110 | 111 | assert expected == actual 112 | 113 | 114 | def test_first_key_is_list(): 115 | expected = { 116 | "books": [ 117 | { 118 | "author": {"first_name": "John"}, 119 | }, 120 | { 121 | "author": {"last_name": "Vonnegut"}, 122 | }, 123 | ], 124 | } 125 | 126 | data = { 127 | "books": [ 128 | { 129 | "title": "The Grapes of Wrath", 130 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 131 | }, 132 | { 133 | "title": "Slaughterhouse-Five", 134 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 135 | }, 136 | ], 137 | } 138 | 139 | transformer = Transformer(data) 140 | 141 | spec = ( 142 | "books.0.author.first_name", 143 | "books.1.author.last_name", 144 | ) 145 | actual = transformer.transform(spec=spec) 146 | 147 | assert expected == actual 148 | 149 | 150 | def test_list_with_nested(transformer): 151 | expected = { 152 | "library": { 153 | "books": [ 154 | { 155 | "author": {"last_name": "Steinbeck"}, 156 | }, 157 | { 158 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 159 | }, 160 | ], 161 | } 162 | } 163 | 164 | spec = ( 165 | "library.books.0.author.last_name", 166 | "library.books.1.author", 167 | ) 168 | actual = transformer.transform(spec=spec) 169 | 170 | assert expected == actual 171 | 172 | 173 | def test_entire_list(transformer): 174 | expected = { 175 | "library": { 176 | "books": [ 177 | { 178 | "title": "The Grapes of Wrath", 179 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 180 | }, 181 | { 182 | "title": "Slaughterhouse-Five", 183 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 184 | }, 185 | ], 186 | } 187 | } 188 | 189 | spec = ("library.books",) 190 | actual = transformer.transform(spec=spec) 191 | 192 | assert expected == actual 193 | 194 | 195 | def test_list_with_index(transformer): 196 | expected = { 197 | "library": { 198 | "books": [ 199 | { 200 | "title": "The Grapes of Wrath", 201 | "author": {"first_name": "John", "last_name": "Steinbeck"}, 202 | }, 203 | { 204 | "author": {"first_name": "Kurt", "last_name": "Vonnegut"}, 205 | }, 206 | ], 207 | } 208 | } 209 | 210 | spec = ( 211 | "library.books.0", 212 | "library.books.1.author", 213 | ) 214 | actual = transformer.transform(spec=spec) 215 | 216 | assert expected == actual 217 | --------------------------------------------------------------------------------