diff --git a/README.md b/README.md index 334ae39..edc3af3 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,23 @@ pip install yaml-patch You can pass any number of patches to be applied, they use the following syntax options: -### Patch a single value: +### Override a single value: `.=` Example: ```bash -yaml-patch -f test.yml "spec.replicas=2" +yaml-patch -f test.yml "spec.replicas=2" ``` -### Patch a value inside a single list item: +### Override a value inside a single list item: `.[.=` Example: ```bash -yaml-patch -f test.yml "spec.template.containers.[0].image='mycontainer:latest'" +yaml-patch -f test.yml "spec.template.containers.[0].image='mycontainer:latest'" ``` -### Patch a value inside all list items: +### Override a value inside all list items: `.[].=` Example: @@ -41,14 +41,32 @@ Example: yaml-patch -f test.yml "spec.template.containers.[].image='mycontainer:latest'" ``` +### Append a single value: +`.+=` + +Example (increment int): +```bash +yaml-patch -f test.yml "spec.replicas+=2" +``` + +Example (append string): +```bash +yaml-patch -f test.yml "spec.template.containers.[0].image+=':latest'" +``` + +Example (append item to list): +```bash +yaml-patch -f test.yml "spec.template.containers.[0].args+=['--verbose']" +``` + ## As a Python library -To use `yaml-patch` as a library just import the function and pass patches as dictionary entries. +To use `yaml-patch` as a library just import the function and pass patches as you would in the CLI examples above. Example: ```python -from yaml_patch import patch +from yaml_patch import patch_yaml from textwrap import dedent def override_list_all_values(): @@ -59,7 +77,7 @@ def override_list_all_values(): - bob """ ) - patches = {"some_list.[]": "charlie"} + patches = ["some_list.[]='charlie'"] expected_yaml = dedent( """\ some_list: @@ -67,5 +85,5 @@ def override_list_all_values(): - charlie """ ) - assert patch(source_yaml, patches) == expected_yaml + assert patch_yaml(source_yaml, patches) == expected_yaml ``` \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 83e83a9..a4e8cca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,46 +13,38 @@ def test_no_patches(): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, input="key: value") __assert_result_success(result) assert result.stdout == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: value", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: value", patches=tuple()) def test_patches(): """ - Test that patch arguments from CLI are parsed correctly + Test that patch arguments from CLI are forwarded correctly """ runner = CliRunner() - cli_patches = [ + cli_patches = ( "key='new value'", "key.subkey='new subvalue'", "key.list.[0]='value in list'", "key.subint=5", "key.subbool=true", - ] - - expected_patches = { - "key": "new value", - "key.subkey": "new subvalue", - "key.list.[0]": "value in list", - "key.subint": 5, - "key.subbool": True, - } + ) - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, cli_patches, input="key: value") __assert_result_success(result) assert result.stdout == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: value", patches=expected_patches) + mock_patch.assert_called_once_with(yaml_contents="key: value", patches=cli_patches) def test_patch_file_input(tmp_path): @@ -64,14 +56,14 @@ def test_patch_file_input(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, [f"--file={tmp_yaml}"]) __assert_result_success(result) assert result.stdout == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=tuple()) def test_patch_file_output(tmp_path): @@ -83,7 +75,7 @@ def test_patch_file_output(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, [f"--output={tmp_yaml}"], input="key: value") @@ -91,7 +83,7 @@ def test_patch_file_output(tmp_path): __assert_result_success(result) assert result.stdout == "" # stdout is empty because we output to a file assert tmp_yaml.read_text() == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: value", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: value", patches=tuple()) def test_patch_file_input_and_output(tmp_path): @@ -104,7 +96,7 @@ def test_patch_file_input_and_output(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, [f"--file={input_yaml}", f"--output={output_yaml}"], input="key: value") @@ -112,7 +104,7 @@ def test_patch_file_input_and_output(tmp_path): __assert_result_success(result) assert result.stdout == "" # stdout is empty because we output to a file assert output_yaml.read_text() == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=tuple()) def test_patch_file_same_input_and_output(tmp_path): @@ -124,7 +116,7 @@ def test_patch_file_same_input_and_output(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, [f"--file={tmp_yaml}", f"--output={tmp_yaml}"], input="key: value") @@ -132,7 +124,7 @@ def test_patch_file_same_input_and_output(tmp_path): __assert_result_success(result) assert result.stdout == "" # stdout is empty because we output to a file assert tmp_yaml.read_text() == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=tuple()) def test_inplace(tmp_path): @@ -144,7 +136,7 @@ def test_inplace(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, [f"--file={tmp_yaml}", "--in-place"], input="key: value") @@ -152,7 +144,7 @@ def test_inplace(tmp_path): __assert_result_success(result) assert result.stdout == "" # stdout is empty because we output to a file assert tmp_yaml.read_text() == "mock_output" - mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=dict()) + mock_patch.assert_called_once_with(yaml_contents="key: 'value in file'", patches=tuple()) def test_cant_use_inplace_with_stdin(tmp_path): @@ -164,7 +156,7 @@ def test_cant_use_inplace_with_stdin(tmp_path): runner = CliRunner() - with mock.patch("yaml_patch.patch", return_value="mock_output") as mock_patch: + with mock.patch("yaml_patch.patch_yaml", return_value="mock_output") as mock_patch: from yaml_patch.cli import cli result = runner.invoke(cli, ["--in-place"], input="key: value") diff --git a/tests/test_patch.py b/tests/test_patch.py index c0f33f1..fcaa0f4 100644 --- a/tests/test_patch.py +++ b/tests/test_patch.py @@ -1,6 +1,8 @@ from textwrap import dedent -from yaml_patch import patch +import pytest + +from yaml_patch import patch_yaml, patch def check(obtained, expected): @@ -132,3 +134,52 @@ def test_override_object_within_a_list(): """ ) check(patch(source_yaml, patches), expected_yaml) + + +def test_append_to_int(): + source_yaml = dedent( + """\ + some_key: 1 + """ + ) + patches = ["some_key+=2"] + expected_yaml = dedent( + """\ + some_key: 3 + """ + ) + check(patch_yaml(source_yaml, patches), expected_yaml) + + +def test_append_to_list(): + source_yaml = dedent( + """\ + some_list: + - alice + - bob + """ + ) + patches = ["some_list+=['charlie']"] + expected_yaml = dedent( + """\ + some_list: + - alice + - bob + - charlie + """ + ) + check(patch_yaml(source_yaml, patches), expected_yaml) + + +def test_append_str_to_list(): + source_yaml = dedent( + """\ + some_list: + - alice + - bob + """ + ) + patches = ["some_list+='charlie'"] + with pytest.raises(TypeError) as e: + patch_yaml(source_yaml, patches) + assert str(e.value) == 'can only concatenate list (not "str") to list' diff --git a/yaml_patch/__init__.py b/yaml_patch/__init__.py index 11e5cf0..c5b0ba0 100644 --- a/yaml_patch/__init__.py +++ b/yaml_patch/__init__.py @@ -1,3 +1,3 @@ -from .patch import patch +from .patch import patch_yaml, patch -__all__ = ["patch"] +__all__ = ["patch_yaml", "patch"] diff --git a/yaml_patch/cli.py b/yaml_patch/cli.py index 4f537f7..a2e9534 100644 --- a/yaml_patch/cli.py +++ b/yaml_patch/cli.py @@ -53,20 +53,24 @@ def cli(file, output, in_place, patches): if in_place and file == sys.stdin: raise ValueError("Cannot use --in-place with stdin as the source") - # Split each patch into key+value separated by `=`. Use YAML to load the values coming from command line to ensure - # they are parsed into yaml syntax equivalents (automatically detect strings, ints, bools, etc). - from ruamel.yaml import YAML - - yaml = YAML() - dict_patches = dict() - for p in patches: - k, v = p.split("=") - dict_patches[k] = yaml.load(v) - - # Apply patches - from yaml_patch import patch - - patched = patch(yaml_contents=file.read(), patches=dict_patches) + from yaml_patch import patch_yaml + + patched = patch_yaml(yaml_contents=file.read(), patches=patches) + + # # Split each patch into key+value separated by `=`. Use YAML to load the values coming from command line to ensure + # # they are parsed into yaml syntax equivalents (automatically detect strings, ints, bools, etc). + # from ruamel.yaml import YAML + # + # yaml = YAML() + # dict_patches = dict() + # for p in patches: + # k, v = p.split("=") + # dict_patches[k] = yaml.load(v) + # + # # Apply patches + # from yaml_patch import patch + # + # patched = patch(yaml_contents=file.read(), patches=dict_patches) # Output results if in_place: diff --git a/yaml_patch/patch.py b/yaml_patch/patch.py index ed97004..8f20c39 100644 --- a/yaml_patch/patch.py +++ b/yaml_patch/patch.py @@ -1,12 +1,12 @@ from __future__ import annotations import io -from typing import Union +from typing import Union, Sequence, NamedTuple from ruamel.yaml import YAML -def patch(yaml_contents: str, patches: dict[str:object]) -> str: +def patch_yaml(yaml_contents: str, patches: Sequence[str]) -> str: """ Applies patches to a yaml string, keeping most of the formatting and comments. @@ -19,23 +19,29 @@ def patch(yaml_contents: str, patches: dict[str:object]) -> str: A yaml string :param patches: - A dictionary with patches to be applied to `yaml_contents`. + A list of patches to be applied to `yaml_contents`. - Each dictionary key points to a path that will be overridden with the dictionary value, there is a special - syntax for overriding some or all list items. + Each item points is an expression of `{path}{action}{value}` where {path} refers to a path in `yaml_contents`, + {action} is one of `=` (override) or `+=` (append) and {value} is the value being applied (in raw format). Examples: # Override a single value at yaml root - patches = {"root": "new_value"} + patches = ["root='new_value'"] # Override a single value in a subpath - patches = {"root.sub_item": "new_value"} + patches = ["root.sub_item='new_value'"] # Override a single value at a list position - patches = {"root.my_list.[0]": "new_value"} + patches = ["root.my_list.[0]='new_value'"] # Override all values in a list - patches = {"root.my_list.[]": "new_value"} + patches = ["root.my_list.[]='new_value'"] + + # Increment a single value at yaml root + patches = ["root+='new_value'"] + + # Append an item to a list + patches = ["root.my_list+=['new_value']"] See test_patch.py for more examples. :return: @@ -46,11 +52,21 @@ def patch(yaml_contents: str, patches: dict[str:object]) -> str: yaml.indent = 2 yaml.sequence_dash_offset = 2 - # Parse yaml using ruamel to preserve most of the original formatting/comments + # Parse yaml using ruamel.yaml to preserve most of the original formatting/comments parsed = yaml.load(yaml_contents) - for patch_path, patch_value in patches.items(): - _apply_patch(parsed, patch_path, patch_value) + # Split each patch into key+value separated by an action Use YAML to load the values coming from command line to + # ensure they are parsed into yaml syntax equivalents (automatically detect strings, ints, bools, etc). + yamlparser = YAML() + for p in patches: + if "+=" in p: + path, value = p.split("+=") + action = _patch._action_append + else: + path, value = p.split("=") + action = _patch._action_set + + _apply_patch(parsed, path, action, yamlparser.load(value)) # ruamel.yaml only lets us dump to a stream, so we have to do the StringIO dance output = io.StringIO() @@ -58,42 +74,67 @@ def patch(yaml_contents: str, patches: dict[str:object]) -> str: return output.getvalue() -def _apply_patch(data: Union[dict, list], path: str, patch_value: object): +def patch(yaml_contents: str, patches: dict[str:object]) -> str: + """ + Deprecated. + + Calls patch.yaml using `=` as the action to apply on all patches. + """ + return patch_yaml(yaml_contents, [path + "=" + repr(value) for path, value in patches.items()]) + + +class _patch(NamedTuple): + path: str + action: callable + value: object + + @staticmethod + def _action_set(data, path, value): + data[path] = value + + @staticmethod + def _action_append(data, path, value): + data[path] = data[path] + value # Use `+` instead of `+=` because they are not the same in ruamel.yaml types + + +def _apply_patch(data: Union[dict, list], path: str, action: callable, patch_value: object): """ Apply a single patch to a path. This will recurse deeper into the path if necessary. """ # If this is the final path segment, apply the patch if "." not in path: - _apply_patch_to_value(data, path, patch_value) + _apply_patch_to_value(data, path, action, patch_value) return # If this is the not final path segment, recurse - _apply_patch_to_subpath(data, path, patch_value) + _apply_patch_to_subpath(data, path, action, patch_value) return -def _apply_patch_to_value(data: Union[dict, list], path: str, patch_value: object): +def _apply_patch_to_value(data: Union[dict, list], path: str, action: callable, patch_value: object): """ Apply a single patch to a final path. """ # Patch all list items if path == "[]": for position in range(len(data)): - data[position] = patch_value + action(data, position, patch_value) + # data[position] = patch_value return # Patch single list item if path.startswith("[") and path.endswith("]"): position = int(path[1:-1]) - data[position] = patch_value + action(data, position, patch_value) + # data[position] = patch_value return # Patch value - data[path] = patch_value + action(data, path, patch_value) return -def _apply_patch_to_subpath(data: Union[dict, list], path: str, patch_value: object): +def _apply_patch_to_subpath(data: Union[dict, list], path: str, action: callable, patch_value: object): """ Apply a single patch to a non-final path. This will recurse deeper into the path """ @@ -102,14 +143,14 @@ def _apply_patch_to_subpath(data: Union[dict, list], path: str, patch_value: obj # Recurse on all list items if path == "[]": for position in range(len(data)): - _apply_patch(data[position], subpath, patch_value) + _apply_patch(data[position], subpath, action, patch_value) return # Recurse on single list item if path.startswith("[") and path.endswith("]"): position = int(path[1:-1]) - _apply_patch(data[position], subpath, patch_value) + _apply_patch(data[position], subpath, action, patch_value) return # Recurse on object - return _apply_patch(data[path], subpath, patch_value) + return _apply_patch(data[path], subpath, action, patch_value)