Skip to content

Commit

Permalink
--write: Introduce TransformMixin for Rules (#2023)
Browse files Browse the repository at this point in the history
  • Loading branch information
cognifloyd authored Mar 19, 2022
1 parent 8d14bde commit 93d7c09
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 3 deletions.
40 changes: 40 additions & 0 deletions src/ansiblelint/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from importlib.abc import Loader
from typing import Any, Dict, Iterator, List, Optional, Set, Union

from ruamel.yaml.comments import CommentedMap, CommentedSeq

import ansiblelint.skip_utils
import ansiblelint.utils
import ansiblelint.yaml_utils
Expand Down Expand Up @@ -200,6 +202,44 @@ def matchyaml(self, file: Lintable) -> List[MatchError]:
return matches


class TransformMixin: # pylint: disable=too-few-public-methods
"""A mixin for AnsibleLintRule to enable transforming files.
If ansible-lint is started with the ``--write`` option, then the ``Transformer``
will call the ``transform()`` method for every MatchError identified if the rule
that identified it subclasses this ``TransformMixin``. Only the rule that identified
a MatchError can do transforms to fix that match.
"""

def transform(
self,
match: MatchError,
lintable: Lintable,
data: Union[CommentedMap, CommentedSeq, str],
) -> None:
"""Transform ``data`` to try to fix the MatchError identified by this rule.
The ``match`` was generated by this rule in the ``lintable`` file.
When ``transform()`` is called on a rule, the rule should either fix the
issue, if possible, or make modifications that make it easier to fix manually.
For YAML files, ``data`` is an editable YAML dict/array that preserves
any comments that were in the original file.
.. code:: python
data[0]["tasks"][0]["when"] = False
For any files that aren't YAML, data is the loaded file's content as a string.
To edit non-YAML files, save the updated contents update ``lintable.content``:
.. code:: python
new_data = self.do_something_to_fix_the_match(data)
lintable.content = new_data
"""


def is_valid_rule(rule: Any) -> bool:
"""Check if given rule is valid or not."""
return issubclass(rule, AnsibleLintRule) and bool(rule.id) and bool(rule.shortdesc)
Expand Down
27 changes: 24 additions & 3 deletions src/ansiblelint/transformer.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Transformer implementation."""
import logging
from typing import Dict, List, Set, Union
from typing import Dict, List, Optional, Set, Union

from ruamel.yaml.comments import CommentedMap, CommentedSeq

from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import TransformMixin
from ansiblelint.runner import LintResult
from ansiblelint.yaml_utils import FormattedYAML

Expand Down Expand Up @@ -50,7 +51,7 @@ def __init__(self, result: LintResult):

def run(self) -> None:
"""For each file, read it, execute transforms on it, then write it."""
for file, _ in self.matches_per_file.items():
for file, matches in self.matches_per_file.items():
# str() convinces mypy that "text/yaml" is a valid Literal.
# Otherwise, it thinks base_kind is one of playbook, meta, tasks, ...
file_is_yaml = str(file.base_kind) == "text/yaml"
Expand All @@ -62,17 +63,37 @@ def run(self) -> None:
data = ""
file_is_yaml = False

ruamel_data: Optional[Union[CommentedMap, CommentedSeq]] = None
if file_is_yaml:
# We need a fresh YAML() instance for each load because ruamel.yaml
# stores intermediate state during load which could affect loading
# any other files. (Based on suggestion from ruamel.yaml author)
yaml = FormattedYAML()

ruamel_data: Union[CommentedMap, CommentedSeq] = yaml.loads(data)
ruamel_data = yaml.loads(data)
if not isinstance(ruamel_data, (CommentedMap, CommentedSeq)):
# This is an empty vars file or similar which loads as None.
# It is not safe to write this file or data-loss is likely.
# Only maps and sequences can preserve comments. Skip it.
continue

self._do_transforms(file, ruamel_data or data, matches)

if file_is_yaml:
# noinspection PyUnboundLocalVariable
file.content = yaml.dumps(ruamel_data)

if file.updated:
file.write()

@staticmethod
def _do_transforms(
file: Lintable,
data: Union[CommentedMap, CommentedSeq, str],
matches: List[MatchError],
) -> None:
"""Do Rule-Transforms handling any last-minute MatchError inspections."""
for match in sorted(matches):
if not isinstance(match.rule, TransformMixin):
continue
match.rule.transform(match, file, data)

0 comments on commit 93d7c09

Please sign in to comment.