44# Copyright (c) 2018, Ansible Project
55from __future__ import annotations
66
7- import copy
87import logging
9- import os
108import sys
119from pathlib import Path
12- from typing import TYPE_CHECKING
10+ from typing import TYPE_CHECKING , Any
1311
12+ from ruamel .yaml .comments import CommentedMap
13+
14+ from ansiblelint .errors import MatchError
15+ from ansiblelint .file_utils import Lintable
1416from ansiblelint .rules import AnsibleLintRule , TransformMixin
1517from ansiblelint .runner import get_matches
1618from ansiblelint .transformer import Transformer
1719
1820if TYPE_CHECKING :
19- from ruamel .yaml .comments import CommentedMap , CommentedSeq
21+ from ruamel .yaml .comments import CommentedSeq
2022
2123 from ansiblelint .config import Options
22- from ansiblelint .errors import MatchError
23- from ansiblelint .file_utils import Lintable
2424 from ansiblelint .utils import Task
2525
2626
2727_logger = logging .getLogger (__name__ )
2828
2929
30+ class LocalActionTransformError (Exception ):
31+ """Exception raised when a local_action is not processed correctly."""
32+
33+ def __init__ (self , message : str ) -> None :
34+ """Initialize the LocalActionTransformError with a message."""
35+ _logger .error (message )
36+
37+
3038class TaskNoLocalActionRule (AnsibleLintRule , TransformMixin ):
3139 """Do not use 'local_action', use 'delegate_to: localhost'."""
3240
@@ -52,37 +60,108 @@ def transform(
5260 lintable : Lintable ,
5361 data : CommentedMap | CommentedSeq | str ,
5462 ) -> None :
55- if match .tag == self .id :
56- # we do not want perform a partial modification accidentally
57- original_target_task = self .seek (match .yaml_path , data )
58- target_task = copy .deepcopy (original_target_task )
59- for _ in range (len (target_task )):
60- k , v = target_task .popitem (False )
61- if k == "local_action" :
62- if isinstance (v , dict ):
63- module_name = v ["module" ]
64- target_task [module_name ] = None
65- target_task ["delegate_to" ] = "localhost"
66- elif isinstance (v , str ):
67- module_name , module_value = v .split (" " , 1 )
68- target_task [module_name ] = module_value
69- target_task ["delegate_to" ] = "localhost"
70- else : # pragma: no cover
71- _logger .debug (
72- "Ignored unexpected data inside %s transform." ,
73- self .id ,
74- )
75- return
63+ """Transform the task to use delegate_to: localhost.
64+
65+ Args:
66+ match: The match object.
67+ lintable: The lintable object.
68+ data: The data to transform.
69+ """
70+ try :
71+ self .perform_transform (match , lintable , data )
72+ except LocalActionTransformError as e :
73+ match .fixed = False
74+ match .message = str (e )
75+ return
76+
77+ def perform_transform (
78+ self ,
79+ match : MatchError ,
80+ lintable : Lintable ,
81+ data : CommentedMap | CommentedSeq | str ,
82+ ) -> None :
83+ """Transform the task to use delegate_to: localhost.
84+
85+ Args:
86+ match: The match object.
87+ lintable: The lintable object.
88+ data: The data to transform.
89+
90+ Raises:
91+ LocalActionTransformError: If the local_action is not dict | str.
92+ """
93+ original_task = self .seek (match .yaml_path , data )
94+ task_location = f"{ lintable .name } :{ match .lineno } "
95+
96+ target_task = {}
97+
98+ for k , v in original_task .items ():
99+ if k == "local_action" :
100+ if isinstance (v , dict ):
101+ target_task .update (self .process_dict (v , task_location ))
102+ elif isinstance (v , str ):
103+ target_task .update (self .process_string (v , task_location ))
76104 else :
77- target_task [k ] = v
105+ err = f"Unsupported local_action type '{ type (v ).__name__ } ' in task at { task_location } "
106+ raise LocalActionTransformError (err )
107+ target_task ["delegate_to" ] = "localhost"
108+ else :
109+ target_task [k ] = v
110+
78111 match .fixed = True
79- original_target_task .clear ()
80- original_target_task .update (target_task )
112+ original_task .clear ()
113+ original_task .update (target_task )
114+
115+ def process_dict (
116+ self , local_action : dict [str , Any ], task_location : str
117+ ) -> dict [str , Any ]:
118+ """Process a dict-form local_action.
119+
120+ Args:
121+ local_action: The local_action dictionary.
122+ task_location: The location of the task.
123+
124+ Returns:
125+ A dictionary with the module and parameters.
126+
127+ Raises:
128+ LocalActionTransformError: If the local_action dictionary is missing a 'module' key.
129+ """
130+ if "module" not in local_action :
131+ err = f"No 'module' key in local_action in task at { task_location } "
132+ raise LocalActionTransformError (err )
133+ return {
134+ local_action ["module" ]: {
135+ k : v for k , v in local_action .items () if k != "module"
136+ }
137+ or None
138+ }
139+
140+ def process_string (
141+ self , local_action : str , task_location : str
142+ ) -> dict [str , str | None ]:
143+ """Process a string-form local_action.
144+
145+ Args:
146+ local_action: The local_action string.
147+ task_location: The location of the task.
148+
149+ Returns:
150+ A dictionary with the module and parameters.
151+
152+ Raises:
153+ LocalActionTransformError: If the local_action string is empty or whitespace.
154+ """
155+ if not local_action or not local_action .strip ():
156+ err = f"Empty local_action string in task at { task_location } "
157+ raise LocalActionTransformError (err )
158+ parts = local_action .split (" " , 1 )
159+ return {parts [0 ]: parts [1 ] if len (parts ) > 1 else None }
81160
82161
83162# testing code to be loaded only with pytest or when executed the rule file
84163if "pytest" in sys .modules :
85- from unittest import mock
164+ import pytest
86165
87166 from ansiblelint .rules import RulesCollection
88167 from ansiblelint .runner import Runner
@@ -94,27 +173,80 @@ def test_local_action(default_rules_collection: RulesCollection) -> None:
94173 rules = default_rules_collection ,
95174 ).run ()
96175
97- assert len (results ) == 1
98- assert results [0 ].tag == "deprecated-local-action"
176+ assert any (result .tag == "deprecated-local-action" for result in results )
177+
178+ @pytest .mark .parametrize (
179+ ("data" , "prefix" ),
180+ (
181+ (
182+ CommentedMap ({"local_action" : True }),
183+ "Unsupported local_action type 'bool'" ,
184+ ),
185+ (
186+ CommentedMap ({"local_action" : 123 }),
187+ "Unsupported local_action type 'int'" ,
188+ ),
189+ (
190+ CommentedMap ({"local_action" : 12.34 }),
191+ "Unsupported local_action type 'float'" ,
192+ ),
193+ (
194+ CommentedMap ({"local_action" : []}),
195+ "Unsupported local_action type 'list'" ,
196+ ),
197+ (CommentedMap ({"local_action" : {}}), "No 'module' key in local_action" ),
198+ (CommentedMap ({"local_action" : "" }), "Empty local_action string" ),
199+ (CommentedMap ({"local_action" : " " }), "Empty local_action string" ),
200+ ),
201+ ids = [
202+ "bool" ,
203+ "int" ,
204+ "float" ,
205+ "list" ,
206+ "empty_dict" ,
207+ "empty_string" ,
208+ "whitespace_string" ,
209+ ],
210+ )
211+ def test_local_action_transform_fail (
212+ caplog : pytest .LogCaptureFixture , data : CommentedMap , prefix : str
213+ ) -> None :
214+ """Test transform functionality for a failure.
215+
216+ Args:
217+ caplog: The pytest fixture for capturing logs.
218+ data: The data to test.
219+ prefix: The expected error prefix.
220+ """
221+ file = "site.yml"
222+ rule = TaskNoLocalActionRule ()
223+ lintable = Lintable (name = file )
224+ match_error = MatchError (message = "error" , lintable = lintable , lineno = 1 )
225+ with pytest .raises (LocalActionTransformError ):
226+ rule .perform_transform (match_error , lintable , data )
227+ assert f"{ prefix } in task at { file } :1" in caplog .text
99228
100- @mock .patch .dict (os .environ , {"ANSIBLE_LINT_WRITE_TMP" : "1" }, clear = True )
101229 def test_local_action_transform (
102230 config_options : Options ,
103- default_rules_collection : RulesCollection ,
231+ monkeypatch : pytest . MonkeyPatch ,
104232 ) -> None :
105233 """Test transform functionality for no-log-password rule."""
234+ monkeypatch .setenv ("ANSIBLE_LINT_WRITE_TMP" , "1" )
235+
106236 playbook = Path ("examples/playbooks/tasks/local_action.yml" )
107237 config_options .write_list = ["all" ]
108238
109239 config_options .lintables = [str (playbook )]
240+ only_local_action_rule : RulesCollection = RulesCollection ()
241+ only_local_action_rule .register (TaskNoLocalActionRule ())
110242 runner_result = get_matches (
111- rules = default_rules_collection ,
243+ rules = only_local_action_rule ,
112244 options = config_options ,
113245 )
114246 transformer = Transformer (result = runner_result , options = config_options )
115247 transformer .run ()
116248 matches = runner_result .matches
117- assert len ( matches ) == 3
249+ assert any ( error . tag == "deprecated-local-action" for error in matches )
118250
119251 orig_content = playbook .read_text (encoding = "utf-8" )
120252 expected_content = playbook .with_suffix (
@@ -126,4 +258,5 @@ def test_local_action_transform(
126258
127259 assert orig_content != transformed_content
128260 assert expected_content == transformed_content
261+
129262 playbook .with_suffix (f".tmp{ playbook .suffix } " ).unlink ()
0 commit comments