Skip to content

Commit 348c385

Browse files
authored
Enable YAML 1.2 support for non-ansible files (#3809)
1 parent 11828f3 commit 348c385

File tree

5 files changed

+90
-32
lines changed

5 files changed

+90
-32
lines changed

.config/dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ matchtasks
208208
matchvar
209209
matchyaml
210210
maxdepth
211+
maxsplit
211212
minversion
212213
mkdir
213214
mkdocs

.github/workflows/tox.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
env:
7171
# Number of expected test passes, safety measure for accidental skip of
7272
# tests. Update value if you add/remove tests.
73-
PYTEST_REQPASS: 834
73+
PYTEST_REQPASS: 845
7474
steps:
7575
- uses: actions/checkout@v4
7676
with:

src/ansiblelint/transformer.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
from ansiblelint.file_utils import Lintable
1010
from ansiblelint.rules import AnsibleLintRule, TransformMixin
11-
from ansiblelint.yaml_utils import FormattedYAML, get_path_to_play, get_path_to_task
11+
from ansiblelint.yaml_utils import (
12+
FormattedYAML,
13+
get_path_to_play,
14+
get_path_to_task,
15+
)
1216

1317
if TYPE_CHECKING:
1418
from ansiblelint.config import Options
@@ -92,7 +96,12 @@ def run(self) -> None:
9296
# We need a fresh YAML() instance for each load because ruamel.yaml
9397
# stores intermediate state during load which could affect loading
9498
# any other files. (Based on suggestion from ruamel.yaml author)
95-
yaml = FormattedYAML()
99+
yaml = FormattedYAML(
100+
# Ansible only uses YAML 1.1, but others files should use newer 1.2 (ruamel.yaml defaults to 1.2)
101+
version=(1, 1)
102+
if file.is_owned_by_ansible()
103+
else None,
104+
)
96105

97106
ruamel_data = yaml.load(data)
98107
if not isinstance(ruamel_data, (CommentedMap, CommentedSeq)):
@@ -108,11 +117,6 @@ def run(self) -> None:
108117
if self.write_set != {"none"}:
109118
self._do_transforms(file, ruamel_data or data, file_is_yaml, matches)
110119

111-
if not file.is_owned_by_ansible():
112-
# Do nothing until we enable support for YAML 1.2
113-
# https://github.com/ansible/ansible-lint/issues/3811
114-
continue
115-
116120
if file_is_yaml:
117121
_logger.debug("Dumping %s using YAML (%s)", file, yaml.version)
118122
# noinspection PyUnboundLocalVariable

src/ansiblelint/yaml_utils.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
if TYPE_CHECKING:
3636
# noinspection PyProtectedMember
3737
from ruamel.yaml.comments import LineCol
38-
from ruamel.yaml.compat import StreamTextType
38+
from ruamel.yaml.compat import StreamTextType, VersionType
3939
from ruamel.yaml.nodes import ScalarNode
4040
from ruamel.yaml.representer import RoundTripRepresenter
4141
from ruamel.yaml.tokens import CommentToken
@@ -44,6 +44,7 @@
4444

4545
_logger = logging.getLogger(__name__)
4646

47+
4748
YAMLLINT_CONFIG = """
4849
extends: default
4950
rules:
@@ -750,13 +751,14 @@ def write_version_directive(self, version_text: Any) -> None:
750751
class FormattedYAML(YAML):
751752
"""A YAML loader/dumper that handles ansible content better by default."""
752753

753-
def __init__(
754+
def __init__( # pylint: disable=too-many-arguments
754755
self,
755756
*,
756757
typ: str | None = None,
757758
pure: bool = False,
758759
output: Any = None,
759760
plug_ins: list[str] | None = None,
761+
version: VersionType | None = None,
760762
):
761763
"""Return a configured ``ruamel.yaml.YAML`` instance.
762764
@@ -816,10 +818,12 @@ def __init__(
816818
tasks:
817819
- name: Task
818820
"""
819-
# Default to reading/dumping YAML 1.1 (ruamel.yaml defaults to 1.2)
820-
self._yaml_version_default: tuple[int, int] = (1, 1)
821-
self._yaml_version: str | tuple[int, int] = self._yaml_version_default
822-
821+
if version:
822+
if isinstance(version, str):
823+
x, y = version.split(".", maxsplit=1)
824+
version = (int(x), int(y))
825+
self._yaml_version_default: VersionType = version
826+
self._yaml_version: VersionType = self._yaml_version_default
823827
super().__init__(typ=typ, pure=pure, output=output, plug_ins=plug_ins)
824828

825829
# NB: We ignore some mypy issues because ruamel.yaml typehints are not great.
@@ -920,16 +924,18 @@ def _defaults_from_yamllint_config() -> dict[str, bool | int | str]:
920924

921925
return cast(dict[str, Union[bool, int, str]], config)
922926

923-
@property # type: ignore[override]
924-
def version(self) -> str | tuple[int, int]:
927+
@property
928+
def version(self) -> VersionType | None:
925929
"""Return the YAML version used to parse or dump.
926930
927931
Ansible uses PyYAML which only supports YAML 1.1. ruamel.yaml defaults to 1.2.
928932
So, we have to make sure we dump yaml files using YAML 1.1.
929933
We can relax the version requirement once ansible uses a version of PyYAML
930934
that includes this PR: https://github.com/yaml/pyyaml/pull/555
931935
"""
932-
return self._yaml_version
936+
if hasattr(self, "_yaml_version"):
937+
return self._yaml_version
938+
return None
933939

934940
@version.setter
935941
def version(self, value: str | tuple[int, int] | None) -> None:
@@ -939,7 +945,11 @@ def version(self, value: str | tuple[int, int] | None) -> None:
939945
So, if a file does not include the directive, it sets this to None.
940946
But, None effectively resets the parsing version to YAML 1.2 (ruamel's default).
941947
"""
942-
self._yaml_version = value if value is not None else self._yaml_version_default
948+
if value is not None:
949+
self._yaml_version = value
950+
elif hasattr(self, "_yaml_version_default"):
951+
self._yaml_version = self._yaml_version_default
952+
# We do nothing if the object did not have a previous default version defined
943953

944954
def load(self, stream: Path | StreamTextType) -> Any:
945955
"""Load YAML content from a string while avoiding known ruamel.yaml issues."""
@@ -977,7 +987,11 @@ def dumps(self, data: Any) -> str:
977987
stream.write(preamble_comment)
978988
self.dump(data, stream)
979989
text = stream.getvalue()
980-
return self._post_process_yaml(text)
990+
strip_version_directive = hasattr(self, "_yaml_version_default")
991+
return self._post_process_yaml(
992+
text,
993+
strip_version_directive=strip_version_directive,
994+
)
981995

982996
def _prevent_wrapping_flow_style(self, data: Any) -> None:
983997
if not isinstance(data, (CommentedMap, CommentedSeq)):
@@ -1065,7 +1079,7 @@ def _pre_process_yaml(self, text: str) -> tuple[str, str | None]:
10651079
return text, "".join(preamble_comments) or None
10661080

10671081
@staticmethod
1068-
def _post_process_yaml(text: str) -> str:
1082+
def _post_process_yaml(text: str, *, strip_version_directive: bool = False) -> str:
10691083
"""Handle known issues with ruamel.yaml dumping.
10701084
10711085
Make sure there's only one newline at the end of the file.
@@ -1077,6 +1091,10 @@ def _post_process_yaml(text: str) -> str:
10771091
10781092
Make sure null list items don't end in a space.
10791093
"""
1094+
# remove YAML directive
1095+
if strip_version_directive and text.startswith("%YAML"):
1096+
text = text.split("\n", 1)[1]
1097+
10801098
text = text.rstrip("\n") + "\n"
10811099

10821100
lines = text.splitlines(keepends=True)

test/test_yaml_utils.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
if TYPE_CHECKING:
1717
from ruamel.yaml.comments import CommentedMap, CommentedSeq
18+
from ruamel.yaml.compat import VersionType
1819
from ruamel.yaml.emitter import Emitter
1920

2021
fixtures_dir = Path(__file__).parent / "fixtures"
@@ -202,8 +203,7 @@ def test_custom_ruamel_yaml_emitter(
202203
assert output == expected_output
203204

204205

205-
@pytest.fixture(name="yaml_formatting_fixtures")
206-
def fixture_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, str]:
206+
def load_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, str]:
207207
"""Get the contents for the formatting fixture files.
208208
209209
To regenerate these fixtures, please run ``pytest --regenerate-formatting-fixtures``.
@@ -220,26 +220,61 @@ def fixture_yaml_formatting_fixtures(fixture_filename: str) -> tuple[str, str, s
220220

221221

222222
@pytest.mark.parametrize(
223-
"fixture_filename",
223+
("before", "after", "version"),
224224
(
225-
pytest.param("fmt-1.yml", id="1"),
226-
pytest.param("fmt-2.yml", id="2"),
227-
pytest.param("fmt-3.yml", id="3"),
228-
pytest.param("fmt-4.yml", id="4"),
229-
pytest.param("fmt-5.yml", id="5"),
225+
pytest.param("---\nfoo: bar\n", "---\nfoo: bar\n", None, id="1"),
226+
# verify that 'on' is not translated to bool (1.2 behavior)
227+
pytest.param("---\nfoo: on\n", "---\nfoo: on\n", None, id="2"),
228+
# When version is manually mentioned by us, we expect to output without version directive
229+
pytest.param("---\nfoo: on\n", "---\nfoo: on\n", (1, 2), id="3"),
230+
pytest.param("---\nfoo: on\n", "---\nfoo: true\n", (1, 1), id="4"),
231+
pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", (1, 1), id="5"),
232+
# verify that in-line directive takes precedence but dumping strips if we mention a specific version
233+
pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", (1, 2), id="6"),
234+
# verify that version directive are kept if present
235+
pytest.param("%YAML 1.1\n---\nfoo: on\n", "---\nfoo: true\n", None, id="7"),
236+
pytest.param(
237+
"%YAML 1.2\n---\nfoo: on\n",
238+
"%YAML 1.2\n---\nfoo: on\n",
239+
None,
240+
id="8",
241+
),
242+
pytest.param("---\nfoo: YES\n", "---\nfoo: true\n", (1, 1), id="9"),
243+
pytest.param("---\nfoo: YES\n", "---\nfoo: YES\n", (1, 2), id="10"),
244+
pytest.param("---\nfoo: YES\n", "---\nfoo: YES\n", None, id="11"),
245+
),
246+
)
247+
def test_fmt(before: str, after: str, version: VersionType) -> None:
248+
"""Tests behavior of formatter in regards to different YAML versions, specified or not."""
249+
yaml = ansiblelint.yaml_utils.FormattedYAML(version=version)
250+
data = yaml.load(before)
251+
result = yaml.dumps(data)
252+
assert result == after
253+
254+
255+
@pytest.mark.parametrize(
256+
("fixture_filename", "version"),
257+
(
258+
pytest.param("fmt-1.yml", (1, 1), id="1"),
259+
pytest.param("fmt-2.yml", (1, 1), id="2"),
260+
pytest.param("fmt-3.yml", (1, 1), id="3"),
261+
pytest.param("fmt-4.yml", (1, 1), id="4"),
262+
pytest.param("fmt-5.yml", (1, 1), id="5"),
230263
),
231264
)
232265
def test_formatted_yaml_loader_dumper(
233-
yaml_formatting_fixtures: tuple[str, str, str],
234-
fixture_filename: str, # noqa: ARG001
266+
fixture_filename: str,
267+
version: tuple[int, int],
235268
) -> None:
236269
"""Ensure that FormattedYAML loads/dumps formatting fixtures consistently."""
237270
# pylint: disable=unused-argument
238-
before_content, prettier_content, after_content = yaml_formatting_fixtures
271+
before_content, prettier_content, after_content = load_yaml_formatting_fixtures(
272+
fixture_filename,
273+
)
239274
assert before_content != prettier_content
240275
assert before_content != after_content
241276

242-
yaml = ansiblelint.yaml_utils.FormattedYAML()
277+
yaml = ansiblelint.yaml_utils.FormattedYAML(version=version)
243278

244279
data_before = yaml.load(before_content)
245280
dump_from_before = yaml.dumps(data_before)

0 commit comments

Comments
 (0)