Skip to content

Commit cb2c6c2

Browse files
feat: support JSONPath (#182)
Use library `jsonpath-ng` to replace own but limited JSONPath implementation. Especially the filter expressions allow more complex replacements within lists. Example: `backend.env[?name=='TEST'].value` Resolves #181.
1 parent 3df97be commit cb2c6c2

File tree

7 files changed

+75
-44
lines changed

7 files changed

+75
-44
lines changed

docs/commands/deploy.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ backend:
1515
tag: 1.0.0 # <- and this one
1616
env:
1717
- name: TEST
18-
value: foo # <- and even one in a list
18+
value: foo # <- and this one in a list, selected via sibling value 'TEST'
1919
```
2020
21-
With the following command GitOps CLI will update both values to `1.1.0` on the default branch.
21+
With the following command GitOps CLI will update all values on the default branch.
2222
2323
```bash
2424
gitopscli deploy \
@@ -30,9 +30,11 @@ gitopscli deploy \
3030
--organisation "deployment" \
3131
--repository-name "myapp-non-prod" \
3232
--file "example/values.yaml" \
33-
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env.[0].value': bar}"
33+
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}"
3434
```
3535
36+
You could also use the list index to replace the latter (`my-app.env.[0].value`). For more details on the underlying *JSONPath* syntax, please refer to the [documenatation of the used library *jsonpath-ng*](https://github.com/h2non/jsonpath-ng#jsonpath-syntax).
37+
3638
### Number Of Commits
3739

3840
Note that by default GitOps CLI will create a separate commit for every value change:
@@ -42,7 +44,7 @@ commit 0dcaa136b4c5249576bb1f40b942bff6ac718144
4244
Author: GitOpsCLI <[email protected]>
4345
Date: Thu Mar 12 15:30:32 2020 +0100
4446
45-
changed 'backend.env.[0].value' to 'bar' in example/values.yaml
47+
changed 'backend.env[?name=='TEST'].value' to 'bar' in example/values.yaml
4648
4749
commit d98913ad8fecf571d5f8c3635f8070b05c43a9ca
4850
Author: GitOpsCLI <[email protected]>
@@ -64,11 +66,11 @@ commit 3b96839e90c35b8decf89f34a65ab6d66c8bab28
6466
Author: GitOpsCLI <[email protected]>
6567
Date: Thu Mar 12 15:30:00 2020 +0100
6668
67-
updated 2 values in example/values.yaml
69+
updated 3 values in example/values.yaml
6870
6971
frontend.tag: '1.1.0'
7072
backend.tag: '1.1.0'
71-
backend.env.[0].value: 'bar'
73+
'backend.env[?name==''TEST''].value': 'bar'
7274
```
7375

7476
### Specific Commit Message
@@ -88,7 +90,7 @@ gitopscli deploy \
8890
--repository-name "myapp-non-prod" \
8991
--commit-message "test commit message" \
9092
--file "example/values.yaml" \
91-
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env.[0].value': bar}"
93+
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}"
9294
```
9395

9496
This will end up in one single commit with your specified commit-message.
@@ -107,7 +109,7 @@ gitopscli deploy \
107109
--organisation "deployment" \
108110
--repository-name "myapp-non-prod" \
109111
--file "example/values.yaml" \
110-
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env.[0].value': bar}" \
112+
--values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" \
111113
--create-pr \
112114
--auto-merge
113115
```

docs/includes/preview-configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Make sure that your *app repository* contains a `.gitops.config.yaml` file. This
2323

2424
1. find repository, branch, and folder containing the template
2525
2. templates for host and namespace name
26-
3. replace values in template files
26+
3. replace values in template files (see [`deploy` command](/gitopscli/commands/deploy/) for details on the key syntax)
2727
4. find repository and branch where the preview should be created (i.e. your *deployment config repository*)
2828
5. message templates used to comment your pull request
2929

gitopscli/commands/deploy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def __update_values(self, git_repo: GitRepo) -> Dict[str, Any]:
7070
except YAMLException as ex:
7171
raise GitOpsException(f"Error loading file: {args.file}") from ex
7272
except KeyError as ex:
73-
raise GitOpsException(f"Key '{key}' not found in file: {args.file}") from ex
73+
raise GitOpsException(str(ex)) from ex
7474

7575
if not updated_value:
7676
logging.info("Yaml property %s already up-to-date", key)

gitopscli/io_api/yaml_util.py

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import re
21
from io import StringIO
32
from typing import Any
43
from ruamel.yaml import YAML, YAMLError
5-
6-
7-
_ARRAY_KEY_SEGMENT_PATTERN = re.compile(r"\[(\d+)\]")
4+
from jsonpath_ng.exceptions import JSONPathError
5+
from jsonpath_ng.ext import parse
86

97
YAML_INSTANCE = YAML()
108
YAML_INSTANCE.preserve_quotes = True # type: ignore
@@ -41,30 +39,24 @@ def yaml_dump(yaml: Any) -> str:
4139

4240

4341
def update_yaml_file(file_path: str, key: str, value: Any) -> bool:
42+
if not key:
43+
raise KeyError("Empty key!")
4444
content = yaml_file_load(file_path)
45-
46-
key_segments = key.split(".") if key else []
47-
current_key_segments = []
48-
parent_item = content
49-
for current_key_segment in key_segments:
50-
current_key_segments.append(current_key_segment)
51-
current_key = ".".join(current_key_segments)
52-
is_array = _ARRAY_KEY_SEGMENT_PATTERN.match(current_key_segment)
53-
if is_array:
54-
current_array_index = int(is_array.group(1))
55-
if not isinstance(parent_item, list) or current_array_index >= len(parent_item):
56-
raise KeyError(f"Key '{current_key}' not found in YAML!")
57-
else:
58-
if not isinstance(parent_item, dict) or current_key_segment not in parent_item:
59-
raise KeyError(f"Key '{current_key}' not found in YAML!")
60-
if current_key == key:
61-
if parent_item[current_array_index if is_array else current_key_segment] == value:
62-
return False # nothing to update
63-
parent_item[current_array_index if is_array else current_key_segment] = value
64-
yaml_file_dump(content, file_path)
65-
return True
66-
parent_item = parent_item[current_array_index if is_array else current_key_segment]
67-
raise KeyError(f"Empty key!")
45+
try:
46+
jsonpath_expr = parse(key)
47+
except JSONPathError as ex:
48+
raise KeyError(f"Key '{key}' is invalid JSONPath expression: {ex}!") from ex
49+
matches = jsonpath_expr.find(content)
50+
if not matches:
51+
raise KeyError(f"Key '{key}' not found in YAML!")
52+
if all(match.value == value for match in matches):
53+
return False # nothing to update
54+
try:
55+
jsonpath_expr.update(content, value)
56+
except TypeError as ex:
57+
raise KeyError(f"Key '{key}' cannot be updated: {ex}!") from ex
58+
yaml_file_dump(content, file_path)
59+
return True
6860

6961

7062
def merge_yaml_element(file_path: str, element_path: str, desired_value: Any) -> None:

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
install_requires=[
1010
"GitPython==3.0.6",
1111
"ruamel.yaml==0.16.5",
12+
"jsonpath-ng==1.5.3",
1213
"atlassian-python-api==1.14.5",
1314
"PyGithub==1.53",
1415
"python-gitlab==2.6.0",

tests/commands/test_deploy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def test_key_not_found(self):
405405
)
406406
with pytest.raises(GitOpsException) as ex:
407407
DeployCommand(args).execute()
408-
self.assertEqual(str(ex.value), "Key 'a.b.c' not found in file: test/file.yml")
408+
self.assertEqual(str(ex.value), "'dummy key error'")
409409

410410
assert self.mock_manager.method_calls == [
411411
call.GitRepoApiFactory.create(args, "ORGA", "REPO"),

tests/io_api/test_yaml_util.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717

1818
class YamlUtilTest(unittest.TestCase):
19+
maxDiff = None
20+
1921
@classmethod
2022
def setUpClass(cls):
2123
cls.tmp_dir = f"/tmp/gitopscli-test-{uuid.uuid4()}"
@@ -117,7 +119,15 @@ def test_update_yaml_file(self):
117119
g: 4 # comment 6
118120
- [hello, world] # comment 7
119121
- foo: # comment 8
120-
bar # comment 9"""
122+
bar # comment 9
123+
- list: # comment 10
124+
- key: k1 # comment 11
125+
value: v1 # comment 12
126+
- key: k2 # comment 13
127+
value: v2 # comment 14
128+
- {key: k3+4, value: v3} # comment 15
129+
- key: k3+4 # comment 16
130+
value: v4 # comment 17"""
121131
)
122132

123133
self.assertTrue(update_yaml_file(test_file, "a.b.c", "2"))
@@ -132,6 +142,11 @@ def test_update_yaml_file(self):
132142
self.assertTrue(update_yaml_file(test_file, "a.e.[2]", "replaced object"))
133143
self.assertFalse(update_yaml_file(test_file, "a.e.[2]", "replaced object")) # already updated
134144

145+
self.assertTrue(update_yaml_file(test_file, "a.e.[*].list[?key=='k3+4'].value", "replaced v3 and v4"))
146+
self.assertFalse(
147+
update_yaml_file(test_file, "a.e.[*].list[?key=='k3+4'].value", "replaced v3 and v4")
148+
) # already updated
149+
135150
expected = """\
136151
a: # comment 1
137152
# comment 2
@@ -144,17 +159,25 @@ def test_update_yaml_file(self):
144159
g: 42 # comment 6
145160
- [hello, tester] # comment 7
146161
- replaced object
162+
- list: # comment 10
163+
- key: k1 # comment 11
164+
value: v1 # comment 12
165+
- key: k2 # comment 13
166+
value: v2 # comment 14
167+
- {key: k3+4, value: replaced v3 and v4} # comment 15
168+
- key: k3+4 # comment 16
169+
value: replaced v3 and v4 # comment 17
147170
"""
148171
actual = self._read_file(test_file)
149172
self.assertEqual(expected, actual)
150173

151174
with pytest.raises(KeyError) as ex:
152175
update_yaml_file(test_file, "x.y", "foo")
153-
self.assertEqual("\"Key 'x' not found in YAML!\"", str(ex.value))
176+
self.assertEqual("\"Key 'x.y' not found in YAML!\"", str(ex.value))
154177

155178
with pytest.raises(KeyError) as ex:
156179
update_yaml_file(test_file, "[42].y", "foo")
157-
self.assertEqual("\"Key '[42]' not found in YAML!\"", str(ex.value))
180+
self.assertEqual("\"Key '[42].y' not found in YAML!\"", str(ex.value))
158181

159182
with pytest.raises(KeyError) as ex:
160183
update_yaml_file(test_file, "a.x", "foo")
@@ -165,12 +188,25 @@ def test_update_yaml_file(self):
165188
self.assertEqual("\"Key 'a.[42]' not found in YAML!\"", str(ex.value))
166189

167190
with pytest.raises(KeyError) as ex:
168-
update_yaml_file(test_file, "a.e.[3]", "foo")
169-
self.assertEqual("\"Key 'a.e.[3]' not found in YAML!\"", str(ex.value))
191+
update_yaml_file(test_file, "a.e.[100]", "foo")
192+
self.assertEqual("\"Key 'a.e.[100]' not found in YAML!\"", str(ex.value))
193+
194+
with pytest.raises(KeyError) as ex:
195+
update_yaml_file(test_file, "a.e.[*].list[?key=='foo'].value", "foo")
196+
self.assertEqual("\"Key 'a.e.[*].list[?key=='foo'].value' not found in YAML!\"", str(ex.value))
170197

171198
with pytest.raises(KeyError) as ex:
172199
update_yaml_file(test_file, "a.e.[2].[2]", "foo")
173-
self.assertEqual("\"Key 'a.e.[2].[2]' not found in YAML!\"", str(ex.value))
200+
self.assertEqual(
201+
"\"Key 'a.e.[2].[2]' cannot be updated: 'str' object does not support item assignment!\"", str(ex.value)
202+
)
203+
204+
with pytest.raises(KeyError) as ex:
205+
update_yaml_file(test_file, "invalid JSONPath", "foo")
206+
self.assertEqual(
207+
"\"Key 'invalid JSONPath' is invalid JSONPath expression: Parse error at 1:8 near token JSONPath (ID)!\"",
208+
str(ex.value),
209+
)
174210

175211
with pytest.raises(KeyError) as ex:
176212
update_yaml_file(test_file, "", "foo")

0 commit comments

Comments
 (0)