Skip to content

Commit af7bcb3

Browse files
committed
Retain block sequence indentation
STONEBLD-3422 Users may use various block sequence indentation styles for themselves that are different from the one proposed by Konflux during the onboard. This commit tries to detect the indentation styles used in the users side and retain the style as much as possible in order to not introduce big formatting change to users. The tool does: * if block sequence indentation style is used consistently with same levels through the whole file, whatever 0, 2, or 4, the result pipeline keeps same style. * if various styles are mixed, the block sequneces are formatted without indentation in the result pipeline. This commit makes these changes mainly: * Add a new class YAMLStyle for detecting the indentation sytles. * YAML related methods in utils.py are extended to support dumping data with styles. * Move fixed styles width and preserve_quotes into YAMLStyle class. * resolve_pipeline method ensures the styles are detected and save the pipeline with detected style. * Tests are added and updated accordingly. The tests involves YAMLs with different styles of block sequence indentations, then part of the test code are refactored with pytest fixture in order to write tests easily.
1 parent 042e50d commit af7bcb3

File tree

6 files changed

+288
-34
lines changed

6 files changed

+288
-34
lines changed

src/pipeline_migration/migrate.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from pipeline_migration.utils import dump_yaml, load_yaml
1515
from pipeline_migration.registry import Container, Registry, ImageIndex
1616
from pipeline_migration.quay import QuayTagInfo, list_active_repo_tags
17-
from pipeline_migration.utils import is_true
1817
from pipeline_migration.types import FilePath
18+
from pipeline_migration.utils import is_true, YAMLStyle
1919

2020
ANNOTATION_HAS_MIGRATION: Final[str] = "dev.konflux-ci.task.has-migration"
2121
ANNOTATION_IS_MIGRATION: Final[str] = "dev.konflux-ci.task.is-migration"
@@ -105,15 +105,17 @@ def resolve_pipeline(pipeline_file: FilePath) -> Generator[FilePath, Any, None]:
105105
:type pipeline_file: str
106106
:return: a generator yielding a file containing the pipeline definition.
107107
"""
108+
yaml_style = YAMLStyle.detect(pipeline_file)
108109
origin_pipeline = load_yaml(pipeline_file)
110+
109111
if not isinstance(origin_pipeline, dict):
110112
raise ValueError(f"Given file {pipeline_file} is not a YAML mapping.")
111113

112114
kind = origin_pipeline.get("kind")
113115
if kind == TEKTON_KIND_PIPELINE:
114116
yield pipeline_file
115117
pl_yaml = load_yaml(pipeline_file)
116-
dump_yaml(pipeline_file, pl_yaml)
118+
dump_yaml(pipeline_file, pl_yaml, style=yaml_style)
117119
elif kind == TEKTON_KIND_PIPELINE_RUN:
118120
spec = origin_pipeline.get("spec") or {}
119121
if "pipelineSpec" in spec:
@@ -125,7 +127,7 @@ def resolve_pipeline(pipeline_file: FilePath) -> Generator[FilePath, Any, None]:
125127
yield temp_pipeline_file
126128
modified_pipeline = load_yaml(temp_pipeline_file)
127129
spec["pipelineSpec"] = modified_pipeline["spec"]
128-
dump_yaml(pipeline_file, origin_pipeline)
130+
dump_yaml(pipeline_file, origin_pipeline, style=yaml_style)
129131
elif "pipelineRef" in spec:
130132
# Pipeline definition can be referenced here, via either git-resolver or a name field
131133
# pointing to YAML file under the .tekton/.

src/pipeline_migration/utils.py

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,100 @@
1+
from dataclasses import dataclass, field
12
from typing import Any
2-
from ruamel.yaml import YAML
33

44
from pipeline_migration.types import FilePath
55

6+
from ruamel.yaml import YAML, CommentedMap, CommentedSeq
7+
from ruamel.yaml.comments import CommentedBase
8+
9+
10+
__all__ = [
11+
"BlockSequenceIndentation",
12+
"create_yaml_obj",
13+
"dump_yaml",
14+
"is_true",
15+
"load_yaml",
16+
"YAMLStyle",
17+
]
18+
619

720
def is_true(value: str) -> bool:
821
return value.strip().lower() == "true"
922

1023

11-
def create_yaml_obj():
24+
@dataclass
25+
class BlockSequenceIndentation:
26+
indentations: dict[int, int] = field(default_factory=dict)
27+
28+
@property
29+
def is_consistent(self) -> bool:
30+
return len(self.levels) == 1
31+
32+
@property
33+
def levels(self) -> list[int]:
34+
return list(self.indentations.keys())
35+
36+
37+
@dataclass
38+
class _NodePath:
39+
node: CommentedBase
40+
field: str = ""
41+
42+
43+
@dataclass
44+
class YAMLStyle:
45+
indentation: BlockSequenceIndentation
46+
47+
preserve_quotes: bool = True
48+
width: int = 8192
49+
50+
@classmethod
51+
def _detect_block_sequence_indentation(cls, node: CommentedBase) -> BlockSequenceIndentation:
52+
53+
parent_nodes: list[_NodePath] = []
54+
block_seq_indentations = BlockSequenceIndentation()
55+
indentations = block_seq_indentations.indentations
56+
57+
def _walk(node: CommentedBase) -> None:
58+
if isinstance(node, CommentedMap):
59+
parent_nodes.append(_NodePath(node=node))
60+
for key, value in node.items():
61+
parent_nodes[-1].field = key
62+
_walk(value)
63+
parent_nodes.pop()
64+
elif isinstance(node, CommentedSeq):
65+
levels = node.lc.col - parent_nodes[-1].node.lc.col
66+
if levels in indentations:
67+
indentations[levels] += 1
68+
else:
69+
indentations[levels] = 1
70+
for item in node:
71+
_walk(item)
72+
73+
_walk(node)
74+
75+
return block_seq_indentations
76+
77+
@classmethod
78+
def detect(cls, file_path: FilePath) -> "YAMLStyle":
79+
doc = load_yaml(file_path)
80+
indentation = cls._detect_block_sequence_indentation(doc)
81+
return cls(indentation=indentation)
82+
83+
84+
def create_yaml_obj(style: YAMLStyle | None = None):
1285
yaml = YAML()
13-
yaml.preserve_quotes = True
14-
yaml.width = 8192
86+
if style is None:
87+
return yaml
88+
89+
yaml.preserve_quotes = style.preserve_quotes
90+
yaml.width = style.width
91+
92+
offset = 0
93+
if style.indentation.is_consistent:
94+
offset = style.indentation.levels[0]
95+
sequence = offset + 2
96+
yaml.indent(sequence=sequence, offset=offset)
97+
1598
return yaml
1699

17100

@@ -20,6 +103,6 @@ def load_yaml(yaml_file: FilePath) -> Any:
20103
return create_yaml_obj().load(f)
21104

22105

23-
def dump_yaml(yaml_file: FilePath, data: Any):
106+
def dump_yaml(yaml_file: FilePath, data: Any, style: YAMLStyle | None = None) -> None:
24107
with open(yaml_file, "w", encoding="utf-8") as f:
25-
create_yaml_obj().dump(data, f)
108+
create_yaml_obj(style).dump(data, f)

tests/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from copy import deepcopy
3+
from textwrap import dedent
34
from typing import Final
45

56
import responses
@@ -107,3 +108,43 @@ def _mock(c: Container, has_migration=False, previous_migration_bundle: str | No
107108
responses.add(responses.GET, f"https://{c.manifest_url()}", json=manifest_json)
108109

109110
return _mock
111+
112+
113+
@pytest.fixture
114+
def pipeline_yaml():
115+
return dedent(
116+
"""\
117+
apiVersion: tekton.dev/v1
118+
kind: Pipeline
119+
metadata:
120+
name: pl
121+
spec:
122+
tasks:
123+
- name: clone
124+
image: debian:latest
125+
script: |
126+
git clone https://git.host/project
127+
"""
128+
)
129+
130+
131+
@pytest.fixture(params=["no_indents", "2_spaces_indents"])
132+
def pipeline_yaml_with_various_indent_styles(request, pipeline_yaml):
133+
match request.param:
134+
case "no_indents":
135+
return pipeline_yaml
136+
case "2_spaces_indents":
137+
return dedent(
138+
"""\
139+
apiVersion: tekton.dev/v1
140+
kind: Pipeline
141+
metadata:
142+
name: pl
143+
spec:
144+
tasks:
145+
- name: clone
146+
image: debian:latest
147+
script: |
148+
git clone https://git.host/project
149+
"""
150+
)

tests/test_cli.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
ensure_container,
2424
)
2525

26-
from tests.test_migrate import APP_IMAGE_REPO, PIPELINE_DEFINITION, TASK_BUNDLE_CLONE
26+
from pipeline_migration.utils import YAMLStyle
27+
from tests.test_migrate import APP_IMAGE_REPO, TASK_BUNDLE_CLONE
2728
from tests.utils import generate_digest, generate_git_sha
2829

2930

@@ -218,20 +219,29 @@ def _mock_quay_list_tags(self):
218219
json={"tags": tags, "page": 1, "has_additional": False},
219220
)
220221

221-
def _mock_pipeline_file(self, tmp_path: Path) -> Path:
222+
def _mock_pipeline_file(self, tmp_path: Path, content: str) -> Path:
222223
tekton_dir = tmp_path / ".tekton"
223224
tekton_dir.mkdir()
224225
pipeline_file = tekton_dir / "component-pipeline.yaml"
225-
pipeline_file.write_text(PIPELINE_DEFINITION)
226+
pipeline_file.write_text(content)
226227
return pipeline_file
227228

228229
@responses.activate
229230
@pytest.mark.parametrize("use_linked_migrations", [True, False])
230-
def test_apply_migrations(self, use_linked_migrations, monkeypatch, tmp_path):
231+
def test_apply_migrations(
232+
self,
233+
use_linked_migrations,
234+
pipeline_yaml_with_various_indent_styles,
235+
monkeypatch,
236+
tmp_path,
237+
):
231238
monkeypatch.setattr("pipeline_migration.migrate.Registry", MockRegistry)
232239
self._mock_quay_list_tags()
233240

234-
pipeline_file = self._mock_pipeline_file(tmp_path)
241+
pipeline_file = self._mock_pipeline_file(tmp_path, pipeline_yaml_with_various_indent_styles)
242+
243+
# Verified later
244+
origin_style = YAMLStyle.detect(pipeline_file)
235245

236246
tb_upgrades = [
237247
{
@@ -292,6 +302,14 @@ def _subprocess_run(cmd, *args, **kwargs):
292302

293303
assert entry_point() is None
294304

305+
# Verify result formatting
306+
cur_style = YAMLStyle.detect(pipeline_file)
307+
assert cur_style.indentation.is_consistent
308+
if origin_style.indentation.is_consistent:
309+
assert origin_style.indentation.levels == cur_style.indentation.levels
310+
else:
311+
assert cur_style.indentation.levels == [0]
312+
295313

296314
def test_entry_point_should_catch_error(monkeypatch, caplog):
297315
cli_cmd = ["pmt", "--use-legacy-resolver", "-u", json.dumps(UPGRADES)]

tests/test_migrate.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -646,26 +646,10 @@ def _fetch_migration_file(image: str, digest: str) -> str | None:
646646
assert script_content == migrations[0].migration_script
647647

648648

649-
PIPELINE_DEFINITION: Final = dedent(
650-
"""\
651-
apiVersion: tekton.dev/v1
652-
kind: Pipeline
653-
metadata:
654-
name: pl
655-
spec:
656-
tasks:
657-
- name: clone
658-
image: debian:latest
659-
script: |
660-
git clone https://git.host/project
661-
"""
662-
)
663-
664-
665649
class TestApplyMigrations:
666650

667651
@pytest.mark.parametrize("chdir", [True, False])
668-
def test_apply_migrations(self, chdir, tmp_path, monkeypatch):
652+
def test_apply_migrations(self, pipeline_yaml, chdir, tmp_path, monkeypatch):
669653
"""Ensure applying all resolved migrations"""
670654

671655
renovate_upgrades = [
@@ -703,7 +687,7 @@ def test_apply_migrations(self, chdir, tmp_path, monkeypatch):
703687
tekton_dir = tmp_path / ".tekton"
704688
tekton_dir.mkdir()
705689
package_file = tekton_dir / "pipeline.yaml"
706-
package_file.write_text(PIPELINE_DEFINITION)
690+
package_file.write_text(pipeline_yaml)
707691

708692
test_context = {"executed_scripts": []}
709693
counter = itertools.count()
@@ -733,7 +717,9 @@ def subprocess_run(*args, **kwargs):
733717
with pytest.raises(ValueError, match="Pipeline file does not exist: .+"):
734718
manager.apply_migrations()
735719

736-
def test_raise_error_if_migration_process_fails(self, caplog, monkeypatch, tmp_path):
720+
def test_raise_error_if_migration_process_fails(
721+
self, pipeline_yaml, caplog, monkeypatch, tmp_path
722+
):
737723
caplog.set_level(logging.DEBUG, logger="migrate")
738724

739725
renovate_upgrades = [
@@ -771,7 +757,7 @@ def test_raise_error_if_migration_process_fails(self, caplog, monkeypatch, tmp_p
771757
tekton_dir = tmp_path / ".tekton"
772758
tekton_dir.mkdir()
773759
package_file = tekton_dir / "pipeline.yaml"
774-
package_file.write_text(PIPELINE_DEFINITION)
760+
package_file.write_text(pipeline_yaml)
775761

776762
def _mkstemp(*args, **kwargs):
777763
tmp_file_path = tmp_path / "migration_file"

0 commit comments

Comments
 (0)